mirror of
https://github.com/home-assistant/core.git
synced 2026-03-31 12:56:25 +02:00
Compare commits
774 Commits
gha-builde
...
simplify_i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27fe91d74 | ||
|
|
d6458bc574 | ||
|
|
434f1dca2c | ||
|
|
c6ad6da6ae | ||
|
|
be3d65538d | ||
|
|
297e9e265a | ||
|
|
119dfbddea | ||
|
|
970925141e | ||
|
|
51131beaec | ||
|
|
c509226d17 | ||
|
|
067a9a0c25 | ||
|
|
d10197d535 | ||
|
|
8978d197ca | ||
|
|
afc73fdcfd | ||
|
|
31a24446a8 | ||
|
|
e80caaa7cd | ||
|
|
2b3a504a05 | ||
|
|
a93229bd32 | ||
|
|
99306a75d3 | ||
|
|
3a761116e4 | ||
|
|
a6ec59d6a5 | ||
|
|
ca51123115 | ||
|
|
cfc58bd415 | ||
|
|
a18f3cba32 | ||
|
|
6218741602 | ||
|
|
2285db5bb1 | ||
|
|
738b85c17d | ||
|
|
b7bb185d50 | ||
|
|
f4544cf952 | ||
|
|
beab473dcc | ||
|
|
96891228c9 | ||
|
|
a4a36b5cbd | ||
|
|
4a0a400e22 | ||
|
|
fbe4195ae0 | ||
|
|
116fa57903 | ||
|
|
2399da93db | ||
|
|
3850bb0e57 | ||
|
|
f45c84b2a8 | ||
|
|
a2e60f84da | ||
|
|
3757289c73 | ||
|
|
09067a18b7 | ||
|
|
6eb834946b | ||
|
|
0e1663f259 | ||
|
|
0ba3a94a3b | ||
|
|
3562a3800f | ||
|
|
de0efa1639 | ||
|
|
818cf41c22 | ||
|
|
25bfb16936 | ||
|
|
75782e6f17 | ||
|
|
3e5c291338 | ||
|
|
30163fa2e7 | ||
|
|
16231d8d36 | ||
|
|
0c0d6595d6 | ||
|
|
a443060faa | ||
|
|
9807722077 | ||
|
|
12b485b17e | ||
|
|
45def46a45 | ||
|
|
685b921fe7 | ||
|
|
b813aa213f | ||
|
|
79ec3ff484 | ||
|
|
63ba49ce4c | ||
|
|
85c7bf1dff | ||
|
|
894e9bab0a | ||
|
|
b39c83efd2 | ||
|
|
e855b92b82 | ||
|
|
30ee28a0d3 | ||
|
|
78f6b934bb | ||
|
|
fbef3b27bd | ||
|
|
646f56d015 | ||
|
|
f82d21886a | ||
|
|
f5054d41e1 | ||
|
|
53f64bff49 | ||
|
|
65cb9b8528 | ||
|
|
ecd16d759a | ||
|
|
8498e2a715 | ||
|
|
4fa4ba5ad0 | ||
|
|
a953b697ce | ||
|
|
c543743245 | ||
|
|
5b76fab646 | ||
|
|
6153705b61 | ||
|
|
8632420b8f | ||
|
|
4f89715453 | ||
|
|
8ca8c2191f | ||
|
|
cb43950ccf | ||
|
|
ddfef18183 | ||
|
|
ac65ba7d20 | ||
|
|
d76272d74a | ||
|
|
8e5daeb7dd | ||
|
|
5d7abae490 | ||
|
|
f875c77af0 | ||
|
|
c00a68383c | ||
|
|
5544157d5e | ||
|
|
70aa58913d | ||
|
|
cc363e4ebd | ||
|
|
8d28b399b0 | ||
|
|
fe76fe5408 | ||
|
|
a7de418213 | ||
|
|
e359a8952b | ||
|
|
0a9d4ef138 | ||
|
|
5620cfbfd8 | ||
|
|
fb65cf48c9 | ||
|
|
7fd7b2c203 | ||
|
|
69e691f042 | ||
|
|
f690e6de6a | ||
|
|
ee3c2e6f80 | ||
|
|
5ffe301384 | ||
|
|
e5ad6092d1 | ||
|
|
bd79958d10 | ||
|
|
fe485f853f | ||
|
|
3c67c6087a | ||
|
|
cb7f9b5f49 | ||
|
|
2547563e8c | ||
|
|
213b370693 | ||
|
|
2c9ecb394d | ||
|
|
51a5f5793f | ||
|
|
33f11f2263 | ||
|
|
45069b623c | ||
|
|
5defb4dbff | ||
|
|
bc7c3f0617 | ||
|
|
704c0d1eb0 | ||
|
|
6c864a1725 | ||
|
|
299c6556bb | ||
|
|
f0fc98cb66 | ||
|
|
cd63d14e6f | ||
|
|
30dfd23da8 | ||
|
|
d39ef523b8 | ||
|
|
b6c2fbb8c0 | ||
|
|
758d5469aa | ||
|
|
ea99f88d10 | ||
|
|
0a8f76864c | ||
|
|
ad522d723c | ||
|
|
0f41a311c8 | ||
|
|
412a9a050e | ||
|
|
d5efc3abd5 | ||
|
|
a205623d52 | ||
|
|
8208eecf8c | ||
|
|
f84398eb9c | ||
|
|
aca5adb673 | ||
|
|
f361d01b8b | ||
|
|
d2cef2d26e | ||
|
|
90524e53ec | ||
|
|
668d220400 | ||
|
|
9e28db0535 | ||
|
|
c5807463fd | ||
|
|
f72a9e52f5 | ||
|
|
619582bd03 | ||
|
|
bcc02d7adc | ||
|
|
a9083d5362 | ||
|
|
dd89fa0f5b | ||
|
|
88d0bd5a1d | ||
|
|
a045c2907f | ||
|
|
bcca7655f8 | ||
|
|
269ef5f824 | ||
|
|
c80a9aab71 | ||
|
|
33180a658a | ||
|
|
c5955ada1a | ||
|
|
fd7d936a0d | ||
|
|
84cd137bae | ||
|
|
3a77a638d5 | ||
|
|
599f4f01d0 | ||
|
|
bd298e92d0 | ||
|
|
fabbfd93df | ||
|
|
1ecbc44368 | ||
|
|
f30217aa41 | ||
|
|
4d565e6089 | ||
|
|
faaa87e36f | ||
|
|
cd142833e7 | ||
|
|
434e1e5a69 | ||
|
|
a0ef23097f | ||
|
|
4d7bd49d2c | ||
|
|
a73157e739 | ||
|
|
6260bd9abc | ||
|
|
ec7aaeb8e2 | ||
|
|
81e92e2567 | ||
|
|
92fed08095 | ||
|
|
6c1ad5aba4 | ||
|
|
6b1a5219a3 | ||
|
|
b3efa472b5 | ||
|
|
2cc8934bbd | ||
|
|
a22083de10 | ||
|
|
2c8b8007c1 | ||
|
|
c815090ece | ||
|
|
94acb8102f | ||
|
|
8c73dcad91 | ||
|
|
c8f7d9dd42 | ||
|
|
b522db1daf | ||
|
|
338836cba2 | ||
|
|
f5e7605502 | ||
|
|
22ddb18ce2 | ||
|
|
b541dc0a97 | ||
|
|
15d0a01833 | ||
|
|
71be2073eb | ||
|
|
e6886fc562 | ||
|
|
7f0f038bcd | ||
|
|
686ab66a52 | ||
|
|
7a4f953fa6 | ||
|
|
cd0834bfbe | ||
|
|
c598aa6964 | ||
|
|
5ef28932e5 | ||
|
|
f2eac87673 | ||
|
|
aeb920e8ef | ||
|
|
8540a27f0d | ||
|
|
fe2d8a31b8 | ||
|
|
f4efc929d6 | ||
|
|
15d7febffd | ||
|
|
0a8f5449f2 | ||
|
|
d2179d9243 | ||
|
|
bf1327e355 | ||
|
|
9afa827eab | ||
|
|
3ae6f8e7a0 | ||
|
|
56962ff907 | ||
|
|
719b9bdc3c | ||
|
|
bb1dc51a6b | ||
|
|
abbbb7df13 | ||
|
|
5a308d11e4 | ||
|
|
6bf487c3f3 | ||
|
|
3162b637ea | ||
|
|
8cc1dd8091 | ||
|
|
83ff038188 | ||
|
|
13a8d7f7a8 | ||
|
|
a721d32889 | ||
|
|
bce65d4f35 | ||
|
|
daa0ddffb9 | ||
|
|
ee7dd329f0 | ||
|
|
00cd07736e | ||
|
|
78871e1766 | ||
|
|
bb6f739861 | ||
|
|
9948431012 | ||
|
|
4f9241be79 | ||
|
|
5215e674b1 | ||
|
|
31b12701dc | ||
|
|
d5ff890a18 | ||
|
|
32221a1ec4 | ||
|
|
a6dd56eed0 | ||
|
|
682eba9773 | ||
|
|
c055972887 | ||
|
|
78e2514b46 | ||
|
|
0af6a86507 | ||
|
|
2367d7c168 | ||
|
|
8d91fd0655 | ||
|
|
171b8dfa89 | ||
|
|
f299b009fa | ||
|
|
91e9eb0ab3 | ||
|
|
a2b91a9ac0 | ||
|
|
a3add179a0 | ||
|
|
6075becbab | ||
|
|
193f519366 | ||
|
|
b6508c2ca4 | ||
|
|
3dc478a357 | ||
|
|
bd407872b0 | ||
|
|
8b696044c3 | ||
|
|
1a772b6df2 | ||
|
|
a880ad2904 | ||
|
|
ea73f2d0f1 | ||
|
|
11351500ea | ||
|
|
86901bfd80 | ||
|
|
d2ef60125f | ||
|
|
471b49f12b | ||
|
|
33e9e663da | ||
|
|
31ff44f1a6 | ||
|
|
9274bd7867 | ||
|
|
e36f9eb639 | ||
|
|
5149932ec8 | ||
|
|
bdd3fc7059 | ||
|
|
c795cbc5a3 | ||
|
|
20dd604292 | ||
|
|
c35a6dc044 | ||
|
|
cbe767c9c5 | ||
|
|
eea3b78665 | ||
|
|
a78a553bab | ||
|
|
7c7af7f0df | ||
|
|
d52ad38dca | ||
|
|
477384ce9b | ||
|
|
d1be6e1c68 | ||
|
|
151eae4d5a | ||
|
|
035e0042fa | ||
|
|
2568db5fdf | ||
|
|
28b1ded702 | ||
|
|
236cd795b9 | ||
|
|
65e90b9b9f | ||
|
|
96c3f3f054 | ||
|
|
bd8e90bb00 | ||
|
|
d488bdad8a | ||
|
|
dec6f955f3 | ||
|
|
bdb74ca37a | ||
|
|
14c0a82284 | ||
|
|
b42bd4909b | ||
|
|
001a1aada6 | ||
|
|
cd28c924ac | ||
|
|
a19c1a7ba1 | ||
|
|
e0d3298e77 | ||
|
|
2296c92a3e | ||
|
|
66311508ad | ||
|
|
d628463471 | ||
|
|
a5f9c400cc | ||
|
|
36051d015a | ||
|
|
65ae221ba7 | ||
|
|
0fd9360249 | ||
|
|
55f56c6632 | ||
|
|
0336ffca77 | ||
|
|
f33bd2de22 | ||
|
|
0599550e04 | ||
|
|
c384d41625 | ||
|
|
57b0456760 | ||
|
|
85c9b00035 | ||
|
|
d9df5f1fab | ||
|
|
f3cea5160b | ||
|
|
ac7b5a2957 | ||
|
|
031830f004 | ||
|
|
39a655e100 | ||
|
|
714411c072 | ||
|
|
94eb1031cc | ||
|
|
fa98eb52ad | ||
|
|
7b1fbbd278 | ||
|
|
b518729367 | ||
|
|
d04c5ccc44 | ||
|
|
d8ba32bc8e | ||
|
|
7ae3c2012d | ||
|
|
05b78a22cf | ||
|
|
0a5589c800 | ||
|
|
9fb5bceeef | ||
|
|
f4cce71d1f | ||
|
|
2209c9e0f7 | ||
|
|
979045bed3 | ||
|
|
d3a8a7e9be | ||
|
|
ca63f299ff | ||
|
|
1e9c8ec32c | ||
|
|
f38f3626fb | ||
|
|
4a3cc511a7 | ||
|
|
b4e012fcdf | ||
|
|
9da9eaf338 | ||
|
|
422d69f2b3 | ||
|
|
583524e841 | ||
|
|
740e21a23b | ||
|
|
9693ca39d1 | ||
|
|
52a0ed6c1c | ||
|
|
1702a594aa | ||
|
|
e6b7ce97f3 | ||
|
|
0b13274271 | ||
|
|
580ae1e81b | ||
|
|
4c802fba7e | ||
|
|
41031b1cad | ||
|
|
ff59604085 | ||
|
|
f9cac69172 | ||
|
|
81a8dee22a | ||
|
|
00d5e89951 | ||
|
|
748f8b78f7 | ||
|
|
191f49a326 | ||
|
|
8178c8afa0 | ||
|
|
557d072a4d | ||
|
|
2d4c96864b | ||
|
|
745dc0e183 | ||
|
|
8d63c9ccbd | ||
|
|
713475ddb0 | ||
|
|
4badc291d9 | ||
|
|
aa83f534c1 | ||
|
|
b3d51a061a | ||
|
|
7e707d757a | ||
|
|
8c71965557 | ||
|
|
4e42478ece | ||
|
|
03c672a4f3 | ||
|
|
66b5a3755c | ||
|
|
6c3917e927 | ||
|
|
e895c1b2fd | ||
|
|
dae971cd98 | ||
|
|
807df50eab | ||
|
|
aa05ff03b3 | ||
|
|
c645bbb3f8 | ||
|
|
319f9fda92 | ||
|
|
f9525ebda7 | ||
|
|
622b92682e | ||
|
|
a81146a227 | ||
|
|
579dd6785d | ||
|
|
84992b875a | ||
|
|
530dcadf19 | ||
|
|
4aa67ddf22 | ||
|
|
8e95b19c4c | ||
|
|
5558b33600 | ||
|
|
0130ac6770 | ||
|
|
26d22e4d62 | ||
|
|
532bc02d66 | ||
|
|
893eac0e84 | ||
|
|
18a6478d9a | ||
|
|
3d1a8fb08c | ||
|
|
3657a8eb07 | ||
|
|
83e8d1878b | ||
|
|
6f635adb6b | ||
|
|
b3f4805afe | ||
|
|
b70651a811 | ||
|
|
dc1e330e4a | ||
|
|
a45da11ec1 | ||
|
|
31c7553e68 | ||
|
|
44e704a6e0 | ||
|
|
2824919a20 | ||
|
|
ebe0e3ace7 | ||
|
|
e151c9c78c | ||
|
|
7287c847f4 | ||
|
|
152e17aee7 | ||
|
|
c53adcb73b | ||
|
|
dab4a72128 | ||
|
|
c94e10efa7 | ||
|
|
ca5ea9ea35 | ||
|
|
63a09d8e28 | ||
|
|
b5a3c2c014 | ||
|
|
ef887c8edc | ||
|
|
d0eb90274d | ||
|
|
cac375dafb | ||
|
|
2c20b62229 | ||
|
|
b5c84b6b7a | ||
|
|
e5f9668ded | ||
|
|
e214ce690a | ||
|
|
a2c64f65e1 | ||
|
|
8bad30234a | ||
|
|
c4545b42d8 | ||
|
|
b0a60d1c42 | ||
|
|
e1e14bee10 | ||
|
|
3529aff4b1 | ||
|
|
16e314ccf1 | ||
|
|
d634fbcad7 | ||
|
|
b84ca80d55 | ||
|
|
41c2c621f0 | ||
|
|
b230e62868 | ||
|
|
12528ec128 | ||
|
|
7f4a7670a2 | ||
|
|
9bdc1b777e | ||
|
|
995e982d7f | ||
|
|
b92698e3d5 | ||
|
|
225052b932 | ||
|
|
34ae51677f | ||
|
|
3616a52b37 | ||
|
|
0128372258 | ||
|
|
21863cd9d7 | ||
|
|
d67caec5c1 | ||
|
|
8286014ae1 | ||
|
|
1ff8d2279a | ||
|
|
5dcbc1d5d9 | ||
|
|
3068653cc7 | ||
|
|
61b1a45889 | ||
|
|
573d4eba02 | ||
|
|
09895aa601 | ||
|
|
aa6a4c7eab | ||
|
|
662c44b125 | ||
|
|
5a80087cf4 | ||
|
|
c28dc32168 | ||
|
|
eef3472c43 | ||
|
|
f9bd9f4982 | ||
|
|
e4620a208d | ||
|
|
c6c5661b4b | ||
|
|
d0154e5019 | ||
|
|
16fb7ed21e | ||
|
|
d0a751abe4 | ||
|
|
c1bd83c9c0 | ||
|
|
a04b168a19 | ||
|
|
b3c27e9f93 | ||
|
|
92e237ade2 | ||
|
|
cbc573a6b1 | ||
|
|
0c059cfc27 | ||
|
|
143ce9d7b3 | ||
|
|
a6aa837d40 | ||
|
|
c58b4a0066 | ||
|
|
5155242ba7 | ||
|
|
085680f6bf | ||
|
|
98ecaaa6d2 | ||
|
|
5ad199fe16 | ||
|
|
413cb98424 | ||
|
|
b38c5bcaf2 | ||
|
|
fa85dfb3b5 | ||
|
|
f0c6a035db | ||
|
|
3f0c200e56 | ||
|
|
a2259ede28 | ||
|
|
24c2b6fe81 | ||
|
|
efc7350e6f | ||
|
|
5f525fc2a1 | ||
|
|
f619a3e7af | ||
|
|
4e43492342 | ||
|
|
39e70071d3 | ||
|
|
6da0936a66 | ||
|
|
5257702530 | ||
|
|
93da5be052 | ||
|
|
e9576452b2 | ||
|
|
c8c6815efd | ||
|
|
60ef69c21d | ||
|
|
d5b7792208 | ||
|
|
fdfc2f4845 | ||
|
|
184d834a91 | ||
|
|
0c98bf2676 | ||
|
|
229e1ee26b | ||
|
|
fdd2db6f23 | ||
|
|
2886863000 | ||
|
|
bf4170938c | ||
|
|
6b84815c57 | ||
|
|
01b873f3bc | ||
|
|
66b1728c13 | ||
|
|
d11668b868 | ||
|
|
ed3f70bc3f | ||
|
|
008eb39c3b | ||
|
|
a085d91a0d | ||
|
|
6395a0abd0 | ||
|
|
0de2e689f1 | ||
|
|
21d06fdace | ||
|
|
c8cf13ba19 | ||
|
|
d9a29bd486 | ||
|
|
bd0145cb8d | ||
|
|
d002b48335 | ||
|
|
c66daf13d3 | ||
|
|
1cae0e3cd3 | ||
|
|
de93d1d52a | ||
|
|
c67438c515 | ||
|
|
fa57f72f37 | ||
|
|
29309d1315 | ||
|
|
130e0db742 | ||
|
|
450d46f652 | ||
|
|
625603839c | ||
|
|
fb66d766a8 | ||
|
|
e5f13b4126 | ||
|
|
4a22f2c93e | ||
|
|
a5c48b190a | ||
|
|
5e1a0e2152 | ||
|
|
9a5516bb1d | ||
|
|
b9172cf4a8 | ||
|
|
8e4dc29226 | ||
|
|
b152f2f9a6 | ||
|
|
abca80dc13 | ||
|
|
6869369ab2 | ||
|
|
c2dde06713 | ||
|
|
e455c05721 | ||
|
|
085df1de19 | ||
|
|
91a1237965 | ||
|
|
680a6bc856 | ||
|
|
152912c258 | ||
|
|
40e8a1b11a | ||
|
|
69dc354669 | ||
|
|
bbe1bf14ae | ||
|
|
5470d8f8a7 | ||
|
|
99fe4b10d0 | ||
|
|
886b6b08ac | ||
|
|
6a1e7c1cca | ||
|
|
d17df13055 | ||
|
|
f73502c77a | ||
|
|
2c37a86bc9 | ||
|
|
fa8e976de7 | ||
|
|
877bca28ad | ||
|
|
a57c65f512 | ||
|
|
7140826dbb | ||
|
|
5fea8d69d7 | ||
|
|
98e3b9962e | ||
|
|
afe19147f8 | ||
|
|
0e7c25488c | ||
|
|
412e85203d | ||
|
|
55ec4a95fd | ||
|
|
6ea9e9a161 | ||
|
|
b56e6d1ff7 | ||
|
|
b502cdd15b | ||
|
|
b7ba85192d | ||
|
|
04d45c8ada | ||
|
|
ba0804fefa | ||
|
|
538b817bf1 | ||
|
|
7efa2d3cac | ||
|
|
3f872fd196 | ||
|
|
b00f6593f1 | ||
|
|
a63516ff71 | ||
|
|
55b082edb6 | ||
|
|
b0c3ede4fd | ||
|
|
84bd1cd336 | ||
|
|
25bbfcc595 | ||
|
|
bf05925c8b | ||
|
|
488d9ad75c | ||
|
|
2dfad3d755 | ||
|
|
7e759bf730 | ||
|
|
9678049e72 | ||
|
|
8602ba2679 | ||
|
|
78c3503b7d | ||
|
|
fbb3b81991 | ||
|
|
26eaf510ee | ||
|
|
5c83d16995 | ||
|
|
388b258d6c | ||
|
|
2c9a5c10da | ||
|
|
5a68bafd69 | ||
|
|
33fce89a2b | ||
|
|
1932f61da3 | ||
|
|
5a231b27b9 | ||
|
|
5617e8c7bc | ||
|
|
2b5b0e9d0f | ||
|
|
732f553b48 | ||
|
|
0a53b227ed | ||
|
|
44b73ab7bd | ||
|
|
538061d512 | ||
|
|
e307ceccb5 | ||
|
|
ea7558c0ad | ||
|
|
c4399b5547 | ||
|
|
d989a83d7b | ||
|
|
d04f3530df | ||
|
|
647d957ffe | ||
|
|
a3f3c87b39 | ||
|
|
447b17a2a4 | ||
|
|
eb2b92687c | ||
|
|
6424e3658e | ||
|
|
d1d8754853 | ||
|
|
c4ff7fa676 | ||
|
|
f1fe1d3956 | ||
|
|
fd0d60b787 | ||
|
|
9ddefaaacd | ||
|
|
5c8df048b1 | ||
|
|
d86d85ec56 | ||
|
|
660f12b683 | ||
|
|
b8238c86e6 | ||
|
|
754828188e | ||
|
|
6992a3c72b | ||
|
|
738d4f662a | ||
|
|
7f33ac72ab | ||
|
|
0891d814fa | ||
|
|
ddab50edcc | ||
|
|
c8ce4eb32d | ||
|
|
22aca8b7af | ||
|
|
770864082f | ||
|
|
14545660e2 | ||
|
|
836353015b | ||
|
|
c57ffd4d78 | ||
|
|
cbebfdf149 | ||
|
|
d8ed9ca66f | ||
|
|
5caf8a5b83 | ||
|
|
c05210683e | ||
|
|
aa8dd4bb66 | ||
|
|
ee7d6157d9 | ||
|
|
adec1d128c | ||
|
|
0a2fc97696 | ||
|
|
2c47e83342 | ||
|
|
e3c6a2184d | ||
|
|
0ba0829350 | ||
|
|
678048e681 | ||
|
|
743eeeae53 | ||
|
|
46555c6d9a | ||
|
|
dbaca0a723 | ||
|
|
9bb2959029 | ||
|
|
0304781fa9 | ||
|
|
e081d28aa4 | ||
|
|
34aa28c72f | ||
|
|
cfa2946db8 | ||
|
|
1b0779347c | ||
|
|
93a281e7af | ||
|
|
6b32e27fd3 | ||
|
|
79928a8c7c | ||
|
|
9146518e13 | ||
|
|
e9c5172f43 | ||
|
|
cce21ad4b9 | ||
|
|
10ec02ca3c | ||
|
|
bdf54491e5 | ||
|
|
0b05d34238 | ||
|
|
4c69a1c5f7 | ||
|
|
6f1f56dcaa | ||
|
|
d0b9991232 | ||
|
|
aacf39be8a | ||
|
|
bf055da82c | ||
|
|
0fb118bcd9 | ||
|
|
954ef7d1f5 | ||
|
|
b091299320 | ||
|
|
52483e18b2 | ||
|
|
57e8683ed7 | ||
|
|
67faace978 | ||
|
|
e4be64fcb1 | ||
|
|
f552b8221f | ||
|
|
55dc5392f9 | ||
|
|
5b93aeae38 | ||
|
|
33610bb1a1 | ||
|
|
6c3cebe413 | ||
|
|
5346895d9b | ||
|
|
05c3f08c6c | ||
|
|
1ce025733d | ||
|
|
1537ea86b8 | ||
|
|
ec137870fa | ||
|
|
816ee7f53e | ||
|
|
6e7eeec827 | ||
|
|
d100477a22 | ||
|
|
98ac6dd2c1 | ||
|
|
6b30969f60 | ||
|
|
e9a6b5d662 | ||
|
|
f95f3f9982 | ||
|
|
3f884a8cd1 | ||
|
|
10f284932e | ||
|
|
e1c4e6dc42 | ||
|
|
0976e7de4e | ||
|
|
ae1012b2f0 | ||
|
|
bb7c4faca5 | ||
|
|
0b1be61336 | ||
|
|
3ec44024a2 | ||
|
|
1200cc5779 | ||
|
|
d632931f74 | ||
|
|
2f9faa53a1 | ||
|
|
718607a758 | ||
|
|
3789156559 | ||
|
|
042ce6f2de | ||
|
|
0a5908002f | ||
|
|
3a5f71e10a | ||
|
|
04e4b05ab0 | ||
|
|
c2c5232899 | ||
|
|
593610094e | ||
|
|
47cb7870ea | ||
|
|
045b626e24 | ||
|
|
bea5468dee | ||
|
|
04fc12cc26 | ||
|
|
fec33ad42b | ||
|
|
07e323f1e9 | ||
|
|
ebe2612713 | ||
|
|
88ca668562 | ||
|
|
1d46ac0b64 | ||
|
|
13a5e6e85f | ||
|
|
d2665f03ff | ||
|
|
80412e4973 | ||
|
|
818d9f774e | ||
|
|
012e78d625 | ||
|
|
74abedbcd2 | ||
|
|
e16fb6b5a5 | ||
|
|
8906e5dcb5 | ||
|
|
10067c208a | ||
|
|
d4143205e9 | ||
|
|
a4da363ff2 | ||
|
|
bc9ae3dad6 | ||
|
|
9e5daaa784 | ||
|
|
ff0a6757cd | ||
|
|
62ffeeccb0 | ||
|
|
1afe00670e | ||
|
|
500ffe8153 | ||
|
|
2cebb28a1b | ||
|
|
80bfba0981 | ||
|
|
882e499375 | ||
|
|
e89aafc8e2 | ||
|
|
66ae5ab543 | ||
|
|
75d39c0b02 | ||
|
|
989133cb16 | ||
|
|
f559f8e014 | ||
|
|
a95207f2ef | ||
|
|
2c28a93ea0 | ||
|
|
3ff97a0820 | ||
|
|
f7a56447ae | ||
|
|
dfd086f253 | ||
|
|
b6a166ce48 | ||
|
|
e93b724ce4 | ||
|
|
d0b25ccc01 | ||
|
|
0a3ef64f28 | ||
|
|
e9ce3ffff9 | ||
|
|
55415b1559 | ||
|
|
0160dbf3a6 | ||
|
|
7dd83b1e8f | ||
|
|
e502f5f249 | ||
|
|
6e93ebc912 | ||
|
|
9a4fdf7f80 | ||
|
|
76d69a5f53 | ||
|
|
ae40c0cf4b | ||
|
|
078647d128 | ||
|
|
8a637c4e5b | ||
|
|
9e9daff26d | ||
|
|
41aeedaa82 | ||
|
|
a8297ae65d | ||
|
|
b7f1171c08 | ||
|
|
226f606cb9 | ||
|
|
9472be39f2 | ||
|
|
67a9e42b19 | ||
|
|
ba1837859f | ||
|
|
4a301eceac | ||
|
|
d138a99e62 | ||
|
|
a431f84dc9 | ||
|
|
aa9534600e | ||
|
|
54fa49e754 | ||
|
|
459b6152f4 | ||
|
|
60c8d997ca | ||
|
|
a598368895 | ||
|
|
2ff1499c48 | ||
|
|
348ddbe124 | ||
|
|
71ed43faf2 | ||
|
|
dc69a90296 | ||
|
|
f5db8e6ba4 | ||
|
|
b82a26ef68 | ||
|
|
0eaaeedf11 | ||
|
|
62e26e53ac |
@@ -3,54 +3,27 @@ name: Home Assistant Integration knowledge
|
||||
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
|
||||
---
|
||||
|
||||
### File Locations
|
||||
## File Locations
|
||||
- **Integration code**: `./homeassistant/components/<integration_domain>/`
|
||||
- **Integration tests**: `./tests/components/<integration_domain>/`
|
||||
|
||||
## Integration Templates
|
||||
## General guidelines
|
||||
|
||||
### Standard Integration Structure
|
||||
```
|
||||
homeassistant/components/my_integration/
|
||||
├── __init__.py # Entry point with async_setup_entry
|
||||
├── manifest.json # Integration metadata and dependencies
|
||||
├── const.py # Domain and constants
|
||||
├── config_flow.py # UI configuration flow
|
||||
├── coordinator.py # Data update coordinator (if needed)
|
||||
├── entity.py # Base entity class (if shared patterns)
|
||||
├── sensor.py # Sensor platform
|
||||
├── strings.json # User-facing text and translations
|
||||
├── services.yaml # Service definitions (if applicable)
|
||||
└── quality_scale.yaml # Quality scale rule status
|
||||
```
|
||||
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
|
||||
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
|
||||
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
|
||||
|
||||
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines:
|
||||
The following platforms have extra guidelines:
|
||||
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
|
||||
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
|
||||
|
||||
### Minimal Integration Checklist
|
||||
- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.)
|
||||
- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry`
|
||||
- [ ] `config_flow.py` with UI configuration support
|
||||
- [ ] `const.py` with `DOMAIN` constant
|
||||
- [ ] `strings.json` with at least config flow text
|
||||
- [ ] Platform files (`sensor.py`, etc.) as needed
|
||||
- [ ] `quality_scale.yaml` with rule status tracking
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply:
|
||||
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
|
||||
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
|
||||
|
||||
### Quality Scale Levels
|
||||
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
|
||||
- **Silver**: Enhanced functionality
|
||||
- **Gold**: Advanced features
|
||||
- **Platinum**: Highest quality standards
|
||||
|
||||
### Quality Scale Progression
|
||||
- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows
|
||||
- **Silver → Gold**: Add device management, diagnostics, translations
|
||||
- **Gold → Platinum**: Add strict typing, async dependencies, websession injection
|
||||
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
|
||||
|
||||
### How Rules Apply
|
||||
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
|
||||
@@ -61,724 +34,7 @@ Home Assistant uses an Integration Quality Scale to ensure code quality and cons
|
||||
- `exempt`: Rule doesn't apply (with reason in comment)
|
||||
- `todo`: Rule needs implementation
|
||||
|
||||
### Example `quality_scale.yaml` Structure
|
||||
```yaml
|
||||
rules:
|
||||
# Bronze (mandatory)
|
||||
config-flow: done
|
||||
entity-unique-id: done
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
|
||||
# Silver (if targeting Silver+)
|
||||
entity-unavailable: done
|
||||
parallel-updates: done
|
||||
|
||||
# Gold (if targeting Gold+)
|
||||
devices: done
|
||||
diagnostics: done
|
||||
|
||||
# Platinum (if targeting Platinum)
|
||||
strict-typing: done
|
||||
```
|
||||
|
||||
**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules.
|
||||
|
||||
## Code Organization
|
||||
|
||||
### Core Locations
|
||||
- Shared constants: `homeassistant/const.py` (use these instead of hardcoding)
|
||||
- Integration structure:
|
||||
- `homeassistant/components/{domain}/const.py` - Constants
|
||||
- `homeassistant/components/{domain}/models.py` - Data models
|
||||
- `homeassistant/components/{domain}/coordinator.py` - Update coordinator
|
||||
- `homeassistant/components/{domain}/config_flow.py` - Configuration flow
|
||||
- `homeassistant/components/{domain}/{platform}.py` - Platform implementations
|
||||
|
||||
### Common Modules
|
||||
- **coordinator.py**: Centralize data fetching logic
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
```
|
||||
- **entity.py**: Base entity definitions to reduce duplication
|
||||
```python
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
_attr_has_entity_name = True
|
||||
```
|
||||
|
||||
### Runtime Data Storage
|
||||
- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data
|
||||
```python
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
client = MyClient(entry.data[CONF_HOST])
|
||||
entry.runtime_data = client
|
||||
```
|
||||
|
||||
### Manifest Requirements
|
||||
- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements`
|
||||
- **Integration Types**: `device`, `hub`, `service`, `system`, `helper`
|
||||
- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`)
|
||||
- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb`
|
||||
- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`)
|
||||
|
||||
### Config Flow Patterns
|
||||
- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1`
|
||||
- **Unique ID Management**:
|
||||
```python
|
||||
await self.async_set_unique_id(device_unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
```
|
||||
- **Error Handling**: Define errors in `strings.json` under `config.error`
|
||||
- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.)
|
||||
|
||||
### Integration Ownership
|
||||
- **manifest.json**: Add GitHub usernames to `codeowners`:
|
||||
```json
|
||||
{
|
||||
"domain": "my_integration",
|
||||
"name": "My Integration",
|
||||
"codeowners": ["@me"]
|
||||
}
|
||||
```
|
||||
|
||||
### Async Dependencies (Platinum)
|
||||
- **Requirement**: All dependencies must use asyncio
|
||||
- Ensures efficient task handling without thread context switching
|
||||
|
||||
### WebSession Injection (Platinum)
|
||||
- **Pass WebSession**: Support passing web sessions to dependencies
|
||||
```python
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
"""Set up integration from config entry."""
|
||||
client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass))
|
||||
```
|
||||
- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx)
|
||||
|
||||
### Data Update Coordinator
|
||||
- **Standard Pattern**: Use for efficient data management
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=5),
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self):
|
||||
try:
|
||||
return await self.client.fetch_data()
|
||||
except ApiError as err:
|
||||
raise UpdateFailed(f"API communication error: {err}")
|
||||
```
|
||||
- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues
|
||||
- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended
|
||||
|
||||
## Integration Guidelines
|
||||
|
||||
### Configuration Flow
|
||||
- **UI Setup Required**: All integrations must support configuration via UI
|
||||
- **Manifest**: Set `"config_flow": true` in `manifest.json`
|
||||
- **Data Storage**:
|
||||
- Connection-critical config: Store in `ConfigEntry.data`
|
||||
- Non-critical settings: Store in `ConfigEntry.options`
|
||||
- **Validation**: Always validate user input before creating entries
|
||||
- **Config Entry Naming**:
|
||||
- ❌ Do NOT allow users to set config entry names in config flows
|
||||
- Names are automatically generated or can be customized later in UI
|
||||
- ✅ Exception: Helper integrations MAY allow custom names in config flow
|
||||
- **Connection Testing**: Test device/service connection during config flow:
|
||||
```python
|
||||
try:
|
||||
await client.get_data()
|
||||
except MyException:
|
||||
errors["base"] = "cannot_connect"
|
||||
```
|
||||
- **Duplicate Prevention**: Prevent duplicate configurations:
|
||||
```python
|
||||
# Using unique ID
|
||||
await self.async_set_unique_id(identifier)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Using unique data
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
```
|
||||
|
||||
### Reauthentication Support
|
||||
- **Required Method**: Implement `async_step_reauth` in config flow
|
||||
- **Credential Updates**: Allow users to update credentials without re-adding
|
||||
- **Validation**: Verify account matches existing unique ID:
|
||||
```python
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}
|
||||
)
|
||||
```
|
||||
|
||||
### Reconfiguration Flow
|
||||
- **Purpose**: Allow configuration updates without removing device
|
||||
- **Implementation**: Add `async_step_reconfigure` method
|
||||
- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch`
|
||||
|
||||
### Device Discovery
|
||||
- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.)
|
||||
```json
|
||||
{
|
||||
"zeroconf": ["_mydevice._tcp.local."]
|
||||
}
|
||||
```
|
||||
- **Discovery Handler**: Implement appropriate `async_step_*` method:
|
||||
```python
|
||||
async def async_step_zeroconf(self, discovery_info):
|
||||
"""Handle zeroconf discovery."""
|
||||
await self.async_set_unique_id(discovery_info.properties["serialno"])
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
|
||||
```
|
||||
- **Network Updates**: Use discovery to update dynamic IP addresses
|
||||
|
||||
### Network Discovery Implementation
|
||||
- **Zeroconf/mDNS**: Use async instances
|
||||
```python
|
||||
aiozc = await zeroconf.async_get_async_instance(hass)
|
||||
```
|
||||
- **SSDP Discovery**: Register callbacks with cleanup
|
||||
```python
|
||||
entry.async_on_unload(
|
||||
ssdp.async_register_callback(
|
||||
hass, _async_discovered_device,
|
||||
{"st": "urn:schemas-upnp-org:device:ZonePlayer:1"}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Bluetooth Integration
|
||||
- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies
|
||||
- **Connectable**: Set `"connectable": true` for connection-required devices
|
||||
- **Scanner Usage**: Always use shared scanner instance
|
||||
```python
|
||||
scanner = bluetooth.async_get_scanner()
|
||||
entry.async_on_unload(
|
||||
bluetooth.async_register_callback(
|
||||
hass, _async_discovered_device,
|
||||
{"service_uuid": "example_uuid"},
|
||||
bluetooth.BluetoothScanningMode.ACTIVE
|
||||
)
|
||||
)
|
||||
```
|
||||
- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts
|
||||
|
||||
### Setup Validation
|
||||
- **Test Before Setup**: Verify integration can be set up in `async_setup_entry`
|
||||
- **Exception Handling**:
|
||||
- `ConfigEntryNotReady`: Device offline or temporary failure
|
||||
- `ConfigEntryAuthFailed`: Authentication issues
|
||||
- `ConfigEntryError`: Unresolvable setup problems
|
||||
|
||||
### Config Entry Unloading
|
||||
- **Required**: Implement `async_unload_entry` for runtime removal/reload
|
||||
- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms`
|
||||
- **Cleanup**: Register callbacks with `entry.async_on_unload`:
|
||||
```python
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
entry.runtime_data.listener() # Clean up resources
|
||||
return unload_ok
|
||||
```
|
||||
|
||||
### Service Actions
|
||||
- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry`
|
||||
- **Validation**: Check config entry existence and loaded state:
|
||||
```python
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def service_action(call: ServiceCall) -> ServiceResponse:
|
||||
if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])):
|
||||
raise ServiceValidationError("Entry not found")
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError("Entry not loaded")
|
||||
```
|
||||
- **Exception Handling**: Raise appropriate exceptions:
|
||||
```python
|
||||
# For invalid input
|
||||
if end_date < start_date:
|
||||
raise ServiceValidationError("End date must be after start date")
|
||||
|
||||
# For service errors
|
||||
try:
|
||||
await client.set_schedule(start_date, end_date)
|
||||
except MyConnectionError as err:
|
||||
raise HomeAssistantError("Could not connect to the schedule") from err
|
||||
```
|
||||
|
||||
### Service Registration Patterns
|
||||
- **Entity Services**: Register on platform setup
|
||||
```python
|
||||
platform.async_register_entity_service(
|
||||
"my_entity_service",
|
||||
{vol.Required("parameter"): cv.string},
|
||||
"handle_service_method"
|
||||
)
|
||||
```
|
||||
- **Service Schema**: Always validate input
|
||||
```python
|
||||
SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required("entity_id"): cv.entity_ids,
|
||||
vol.Required("parameter"): cv.string,
|
||||
vol.Optional("timeout", default=30): cv.positive_int,
|
||||
})
|
||||
```
|
||||
- **Services File**: Create `services.yaml` with descriptions and field definitions
|
||||
|
||||
### Polling
|
||||
- Use update coordinator pattern when possible
|
||||
- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries
|
||||
- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input
|
||||
- **Minimum Intervals**:
|
||||
- Local network: 5 seconds
|
||||
- Cloud services: 60 seconds
|
||||
- **Parallel Updates**: Specify number of concurrent updates:
|
||||
```python
|
||||
PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device
|
||||
# OR
|
||||
PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only)
|
||||
```
|
||||
|
||||
## Entity Development
|
||||
|
||||
### Unique IDs
|
||||
- **Required**: Every entity must have a unique ID for registry tracking
|
||||
- Must be unique per platform (not per integration)
|
||||
- Don't include integration domain or platform in ID
|
||||
- **Implementation**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
def __init__(self, device_id: str) -> None:
|
||||
self._attr_unique_id = f"{device_id}_temperature"
|
||||
```
|
||||
|
||||
**Acceptable ID Sources**:
|
||||
- Device serial numbers
|
||||
- MAC addresses (formatted using `format_mac` from device registry)
|
||||
- Physical identifiers (printed/EEPROM)
|
||||
- Config entry ID as last resort: `f"{entry.entry_id}-battery"`
|
||||
|
||||
**Never Use**:
|
||||
- IP addresses, hostnames, URLs
|
||||
- Device names
|
||||
- Email addresses, usernames
|
||||
|
||||
### Entity Descriptions
|
||||
- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation
|
||||
- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability
|
||||
- **Bad pattern**:
|
||||
```python
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long
|
||||
)
|
||||
```
|
||||
- **Good pattern**:
|
||||
```python
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda
|
||||
round(data["temp_value"] * 1.8 + 32, 1)
|
||||
if data.get("temp_value") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Entity Naming
|
||||
- **Use has_entity_name**: Set `_attr_has_entity_name = True`
|
||||
- **For specific fields**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
def __init__(self, device: Device, field: str) -> None:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
)
|
||||
self._attr_name = field # e.g., "temperature", "humidity"
|
||||
```
|
||||
- **For device itself**: Set `_attr_name = None`
|
||||
|
||||
### Event Lifecycle Management
|
||||
- **Subscribe in `async_added_to_hass`**:
|
||||
```python
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to events."""
|
||||
self.async_on_remove(
|
||||
self.client.events.subscribe("my_event", self._handle_event)
|
||||
)
|
||||
```
|
||||
- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove`
|
||||
- Never subscribe in `__init__` or other methods
|
||||
|
||||
### State Handling
|
||||
- Unknown values: Use `None` (not "unknown" or "unavailable")
|
||||
- Availability: Implement `available()` property instead of using "unavailable" state
|
||||
|
||||
### Entity Availability
|
||||
- **Mark Unavailable**: When data cannot be fetched from device/service
|
||||
- **Coordinator Pattern**:
|
||||
```python
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.identifier in self.coordinator.data
|
||||
```
|
||||
- **Direct Update Pattern**:
|
||||
```python
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity."""
|
||||
try:
|
||||
data = await self.client.get_data()
|
||||
except MyException:
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._attr_native_value = data.value
|
||||
```
|
||||
|
||||
### Extra State Attributes
|
||||
- All attribute keys must always be present
|
||||
- Unknown values: Use `None`
|
||||
- Provide descriptive attributes
|
||||
|
||||
## Device Management
|
||||
|
||||
### Device Registry
|
||||
- **Create Devices**: Group related entities under devices
|
||||
- **Device Info**: Provide comprehensive metadata:
|
||||
```python
|
||||
_attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, device.mac)},
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
manufacturer="My Company",
|
||||
model="My Sensor",
|
||||
sw_version=device.version,
|
||||
)
|
||||
```
|
||||
- For services: Add `entry_type=DeviceEntryType.SERVICE`
|
||||
|
||||
### Dynamic Device Addition
|
||||
- **Auto-detect New Devices**: After initial setup
|
||||
- **Implementation Pattern**:
|
||||
```python
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices])
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
```
|
||||
|
||||
### Stale Device Removal
|
||||
- **Auto-remove**: When devices disappear from hub/account
|
||||
- **Device Registry Update**:
|
||||
```python
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
```
|
||||
- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed
|
||||
|
||||
### Entity Categories
|
||||
- **Required**: Assign appropriate category to entities
|
||||
- **Implementation**: Set `_attr_entity_category`
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
```
|
||||
- Categories include: `DIAGNOSTIC` for system/technical information
|
||||
|
||||
### Device Classes
|
||||
- **Use When Available**: Set appropriate device class for entity type
|
||||
```python
|
||||
class MyTemperatureSensor(SensorEntity):
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
```
|
||||
- Provides context for: unit conversion, voice control, UI representation
|
||||
|
||||
### Disabled by Default
|
||||
- **Disable Noisy/Less Popular Entities**: Reduce resource usage
|
||||
```python
|
||||
class MySignalStrengthSensor(SensorEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
```
|
||||
- Target: frequently changing states, technical diagnostics
|
||||
|
||||
### Entity Translations
|
||||
- **Required with has_entity_name**: Support international users
|
||||
- **Implementation**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "phase_voltage"
|
||||
```
|
||||
- Create `strings.json` with translations:
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"phase_voltage": {
|
||||
"name": "Phase voltage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Translations (Gold)
|
||||
- **Translatable Errors**: Use translation keys for user-facing exceptions
|
||||
- **Implementation**:
|
||||
```python
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_date_before_start_date",
|
||||
)
|
||||
```
|
||||
- Add to `strings.json`:
|
||||
```json
|
||||
{
|
||||
"exceptions": {
|
||||
"end_date_before_start_date": {
|
||||
"message": "The end date cannot be before the start date."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Translations (Gold)
|
||||
- **Dynamic Icons**: Support state and range-based icon selection
|
||||
- **State-based Icons**:
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"tree_pollen": {
|
||||
"default": "mdi:tree",
|
||||
"state": {
|
||||
"high": "mdi:tree-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Range-based Icons** (for numeric values):
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"battery_level": {
|
||||
"default": "mdi:battery-unknown",
|
||||
"range": {
|
||||
"0": "mdi:battery-outline",
|
||||
"90": "mdi:battery-90",
|
||||
"100": "mdi:battery"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- **Location**: `tests/components/{domain}/`
|
||||
- **Coverage Requirement**: Above 95% test coverage for all modules
|
||||
- **Best Practices**:
|
||||
- Use pytest fixtures from `tests.common`
|
||||
- Mock all external dependencies
|
||||
- Use snapshots for complex data structures
|
||||
- Follow existing test patterns
|
||||
|
||||
### Config Flow Testing
|
||||
- **100% Coverage Required**: All config flow paths must be tested
|
||||
- **Test Scenarios**:
|
||||
- All flow initiation methods (user, discovery, import)
|
||||
- Successful configuration paths
|
||||
- Error recovery scenarios
|
||||
- Prevention of duplicate entries
|
||||
- Flow completion after errors
|
||||
|
||||
### Testing
|
||||
- **Integration-specific tests** (recommended):
|
||||
```bash
|
||||
pytest ./tests/components/<integration_domain> \
|
||||
--cov=homeassistant.components.<integration_domain> \
|
||||
--cov-report term-missing \
|
||||
--durations-min=1 \
|
||||
--durations=0 \
|
||||
--numprocesses=auto
|
||||
```
|
||||
|
||||
### Testing Best Practices
|
||||
- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead
|
||||
- **Use snapshot testing** - For verifying entity states and attributes
|
||||
- **Test through integration setup** - Don't test entities in isolation
|
||||
- **Mock external APIs** - Use fixtures with realistic JSON data
|
||||
- **Verify registries** - Ensure entities are properly registered with devices
|
||||
|
||||
### Config Flow Testing Template
|
||||
```python
|
||||
async def test_user_flow_success(hass, mock_api):
|
||||
"""Test successful user flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# Test form submission
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "My Device"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
|
||||
async def test_flow_connection_error(hass, mock_api_error):
|
||||
"""Test connection error handling."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
```
|
||||
|
||||
### Entity Testing Patterns
|
||||
```python
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Overridden fixture to specify platforms to test."""
|
||||
return [Platform.SENSOR] # Or another specific platform as needed.
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the sensor entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
# Ensure entities are correctly assigned to device
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "device_unique_id")}
|
||||
)
|
||||
assert device_entry
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
for entity_entry in entity_entries:
|
||||
assert entity_entry.device_id == device_entry.id
|
||||
```
|
||||
|
||||
### Mock Patterns
|
||||
```python
|
||||
# Modern integration fixture setup
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
title="My Integration",
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
|
||||
unique_id="device_unique_id",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_api() -> Generator[MagicMock]:
|
||||
"""Return a mocked device API."""
|
||||
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
|
||||
api = api_mock.return_value
|
||||
api.get_data.return_value = MyDeviceData.from_json(
|
||||
load_fixture("device_data.json", DOMAIN)
|
||||
)
|
||||
yield api
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return PLATFORMS
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_device_api: MagicMock,
|
||||
platforms: list[Platform],
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
```
|
||||
|
||||
## Debugging & Troubleshooting
|
||||
|
||||
### Common Issues & Solutions
|
||||
- **Integration won't load**: Check `manifest.json` syntax and required fields
|
||||
- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation
|
||||
- **Config flow errors**: Check `strings.json` entries and error handling
|
||||
- **Discovery not working**: Verify manifest discovery configuration and callbacks
|
||||
- **Tests failing**: Check mock setup and async context
|
||||
|
||||
### Debug Logging Setup
|
||||
```python
|
||||
# Enable debug logging in tests
|
||||
caplog.set_level(logging.DEBUG, logger="my_integration")
|
||||
|
||||
# In integration code - use proper logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER.debug("Processing data: %s", data) # Use lazy logging
|
||||
```
|
||||
|
||||
### Validation Commands
|
||||
```bash
|
||||
# Check specific integration
|
||||
python -m script.hassfest --integration-path homeassistant/components/my_integration
|
||||
|
||||
# Validate quality scale
|
||||
# Check quality_scale.yaml against current rules
|
||||
|
||||
# Run integration tests with coverage
|
||||
pytest ./tests/components/my_integration \
|
||||
--cov=homeassistant.components.my_integration \
|
||||
--cov-report term-missing
|
||||
```
|
||||
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations
|
||||
|
||||
@@ -3,17 +3,4 @@
|
||||
Platform exists as `homeassistant/components/<domain>/diagnostics.py`.
|
||||
|
||||
- **Required**: Implement diagnostic data collection
|
||||
- **Implementation**:
|
||||
```python
|
||||
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MyConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": entry.runtime_data.data,
|
||||
}
|
||||
```
|
||||
- **Security**: Never expose passwords, tokens, or sensitive coordinates
|
||||
|
||||
@@ -8,48 +8,10 @@ Platform exists as `homeassistant/components/<domain>/repairs.py`.
|
||||
- Provide specific steps users need to take to resolve the issue
|
||||
- Use friendly, helpful language
|
||||
- Include relevant context (device names, error details, etc.)
|
||||
- **Implementation**:
|
||||
```python
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"outdated_version",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="outdated_version",
|
||||
)
|
||||
```
|
||||
- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`:
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
"outdated_version": {
|
||||
"title": "Device firmware is outdated",
|
||||
"description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **String Content Must Include**:
|
||||
- What the problem is
|
||||
- Why it matters
|
||||
- Exact steps to resolve (numbered list when multiple steps)
|
||||
- What to expect after following the steps
|
||||
- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps
|
||||
- **Severity Guidelines**:
|
||||
- `CRITICAL`: Reserved for extreme scenarios only
|
||||
- `ERROR`: Requires immediate user attention
|
||||
- `WARNING`: Indicates future potential breakage
|
||||
- **Additional Attributes**:
|
||||
```python
|
||||
ir.async_create_issue(
|
||||
hass, DOMAIN, "issue_id",
|
||||
breaks_in_ha_version="2024.1.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="issue_description",
|
||||
)
|
||||
```
|
||||
- Only create issues for problems users can potentially resolve
|
||||
|
||||
6
.github/copilot-instructions.md
vendored
6
.github/copilot-instructions.md
vendored
@@ -1,6 +1,12 @@
|
||||
<!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
|
||||
|
||||
|
||||
|
||||
# Copilot code review instructions
|
||||
|
||||
- Start review comments with a short, one-sentence summary of the suggested fix.
|
||||
- Do not add comments about code style, formatting or linting issues.
|
||||
|
||||
# GitHub Copilot & Claude Code Instructions
|
||||
|
||||
This repository contains the core of Home Assistant, a Python 3 based home automation application.
|
||||
|
||||
570
.github/workflows/builder.yml
vendored
570
.github/workflows/builder.yml
vendored
@@ -57,10 +57,10 @@ jobs:
|
||||
with:
|
||||
type: ${{ env.BUILD_TYPE }}
|
||||
|
||||
# - name: Verify version
|
||||
# uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# ignore-dev: true
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
ignore-dev: true
|
||||
|
||||
- name: Fail if translations files are checked in
|
||||
run: |
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -224,7 +224,6 @@ jobs:
|
||||
matrix:
|
||||
machine:
|
||||
- generic-x86-64
|
||||
- intel-nuc
|
||||
- khadas-vim3
|
||||
- odroid-c2
|
||||
- odroid-c4
|
||||
@@ -248,10 +247,6 @@ jobs:
|
||||
- machine: qemux86-64
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
|
||||
- machine: intel-nuc
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -290,278 +285,279 @@ jobs:
|
||||
${{ steps.tags.outputs.extra_tags }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
# publish_ha:
|
||||
# name: Publish version files
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# needs: ["init", "build_machine"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read
|
||||
# steps:
|
||||
# - name: Checkout the repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Initialize git
|
||||
# uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# name: ${{ secrets.GIT_NAME }}
|
||||
# email: ${{ secrets.GIT_EMAIL }}
|
||||
# token: ${{ secrets.GIT_TOKEN }}
|
||||
#
|
||||
# - name: Update version file
|
||||
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# key: "homeassistant[]"
|
||||
# key-description: "Home Assistant Core"
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
# channel: ${{ needs.init.outputs.channel }}
|
||||
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
#
|
||||
# - name: Update version file (stable -> beta)
|
||||
# if: needs.init.outputs.channel == 'stable'
|
||||
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# key: "homeassistant[]"
|
||||
# key-description: "Home Assistant Core"
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
# channel: beta
|
||||
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
#
|
||||
# publish_container:
|
||||
# name: Publish meta container for ${{ matrix.registry }}
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# id-token: write # For cosign signing
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
# steps:
|
||||
# - name: Install Cosign
|
||||
# uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
# with:
|
||||
# cosign-release: "v2.5.3"
|
||||
#
|
||||
# - name: Login to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
#
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
#
|
||||
# - name: Verify architecture image signatures
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# for arch in $ARCHS; do
|
||||
# echo "Verifying ${arch} image signature..."
|
||||
# cosign verify \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
# --certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
# done
|
||||
# echo "✓ All images verified successfully"
|
||||
#
|
||||
# # Generate all Docker tags based on version string
|
||||
# # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
|
||||
# # Examples:
|
||||
# # 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
|
||||
# # 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
|
||||
# # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
# - name: Generate Docker metadata
|
||||
# id: meta
|
||||
# uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
# with:
|
||||
# images: ${{ matrix.registry }}/home-assistant
|
||||
# sep-tags: ","
|
||||
# tags: |
|
||||
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
|
||||
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
# 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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
#
|
||||
# - name: Copy architecture images to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# # Use imagetools to copy image blobs directly between registries
|
||||
# # This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# for arch in $ARCHS; do
|
||||
# echo "Copying ${arch} image to DockerHub..."
|
||||
# for attempt in 1 2 3; do
|
||||
# if docker buildx imagetools create \
|
||||
# --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
# break
|
||||
# fi
|
||||
# echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
# sleep 10
|
||||
# if [ "${attempt}" -eq 3 ]; then
|
||||
# echo "Failed after 3 attempts"
|
||||
# exit 1
|
||||
# fi
|
||||
# done
|
||||
# cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
# done
|
||||
#
|
||||
# - name: Create and push multi-arch manifests
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# REGISTRY: ${{ matrix.registry }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
# run: |
|
||||
# # Build list of architecture images dynamically
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# ARCH_IMAGES=()
|
||||
# for arch in $ARCHS; do
|
||||
# ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
# done
|
||||
#
|
||||
# # Build list of all tags for single manifest creation
|
||||
# # Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
# TAG_ARGS=()
|
||||
# IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
# for tag in "${TAGS[@]}"; do
|
||||
# TAG_ARGS+=("--tag" "${tag}")
|
||||
# done
|
||||
#
|
||||
# # Create manifest with ALL tags in a single operation (much faster!)
|
||||
# echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
|
||||
# docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
|
||||
#
|
||||
# # Sign each tag separately (signing requires individual tag names)
|
||||
# echo "Signing all tags..."
|
||||
# for tag in "${TAGS[@]}"; do
|
||||
# echo "Signing ${tag}"
|
||||
# cosign sign --yes "${tag}"
|
||||
# done
|
||||
#
|
||||
# echo "All manifests created and signed successfully"
|
||||
#
|
||||
# build_python:
|
||||
# name: Build PyPi package
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# id-token: write # For PyPI trusted publishing
|
||||
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
# steps:
|
||||
# - name: Checkout the repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Set up Python
|
||||
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
# with:
|
||||
# python-version-file: ".python-version"
|
||||
#
|
||||
# - name: Download translations
|
||||
# uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
# with:
|
||||
# name: translations
|
||||
#
|
||||
# - name: Extract translations
|
||||
# run: |
|
||||
# tar xvf translations.tar.gz
|
||||
# rm translations.tar.gz
|
||||
#
|
||||
# - name: Build package
|
||||
# shell: bash
|
||||
# run: |
|
||||
# # Remove dist, build, and homeassistant.egg-info
|
||||
# # when build locally for testing!
|
||||
# pip install build
|
||||
# python -m build
|
||||
#
|
||||
# - name: Upload package to PyPI
|
||||
# uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
# with:
|
||||
# skip-existing: true
|
||||
#
|
||||
# hassfest-image:
|
||||
# name: Build and test hassfest image
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# attestations: write # For build provenance attestation
|
||||
# id-token: write # For build provenance attestation
|
||||
# needs: ["init"]
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# env:
|
||||
# HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
|
||||
# HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
#
|
||||
# - name: Build Docker image
|
||||
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
# with:
|
||||
# context: . # So action will not pull the repository again
|
||||
# file: ./script/hassfest/docker/Dockerfile
|
||||
# load: true
|
||||
# tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
#
|
||||
# - name: Run hassfest against core
|
||||
# run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
#
|
||||
# - name: Push Docker image
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# id: push
|
||||
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
# with:
|
||||
# context: . # So action will not pull the repository again
|
||||
# file: ./script/hassfest/docker/Dockerfile
|
||||
# push: true
|
||||
# tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
|
||||
#
|
||||
# - name: Generate artifact attestation
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
# with:
|
||||
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
# subject-digest: ${{ steps.push.outputs.digest }}
|
||||
# push-to-registry: true
|
||||
|
||||
publish_ha:
|
||||
name: Publish version files
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_machine"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
name: ${{ secrets.GIT_NAME }}
|
||||
email: ${{ secrets.GIT_EMAIL }}
|
||||
token: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Update version file
|
||||
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
key: "homeassistant[]"
|
||||
key-description: "Home Assistant Core"
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
channel: ${{ needs.init.outputs.channel }}
|
||||
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
|
||||
- name: Update version file (stable -> beta)
|
||||
if: needs.init.outputs.channel == 'stable'
|
||||
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
key: "homeassistant[]"
|
||||
key-description: "Home Assistant Core"
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
channel: beta
|
||||
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
|
||||
publish_container:
|
||||
name: Publish meta container for ${{ matrix.registry }}
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify architecture image signatures
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Verifying ${arch} image signature..."
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
echo "✓ All images verified successfully"
|
||||
|
||||
# Generate all Docker tags based on version string
|
||||
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
|
||||
# Examples:
|
||||
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
|
||||
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
tags: |
|
||||
type=raw,value=${{ needs.init.outputs.version }},priority=9999
|
||||
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Use imagetools to copy image blobs directly between registries
|
||||
# This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Copying ${arch} image to DockerHub..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
if [ "${attempt}" -eq 3 ]; then
|
||||
echo "Failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
|
||||
- name: Create and push multi-arch manifests
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
REGISTRY: ${{ matrix.registry }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
# Build list of architecture images dynamically
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
ARCH_IMAGES=()
|
||||
for arch in $ARCHS; do
|
||||
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
done
|
||||
|
||||
# Build list of all tags for single manifest creation
|
||||
# Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
TAG_ARGS=()
|
||||
IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
for tag in "${TAGS[@]}"; do
|
||||
TAG_ARGS+=("--tag" "${tag}")
|
||||
done
|
||||
|
||||
# Create manifest with ALL tags in a single operation (much faster!)
|
||||
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
|
||||
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
|
||||
|
||||
# Sign each tag separately (signing requires individual tag names)
|
||||
echo "Signing all tags..."
|
||||
for tag in "${TAGS[@]}"; do
|
||||
echo "Signing ${tag}"
|
||||
cosign sign --yes "${tag}"
|
||||
done
|
||||
|
||||
echo "All manifests created and signed successfully"
|
||||
|
||||
build_python:
|
||||
name: Build PyPi package
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
id-token: write # For PyPI trusted publishing
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
run: |
|
||||
# Remove dist, build, and homeassistant.egg-info
|
||||
# when build locally for testing!
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Upload package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
hassfest-image:
|
||||
name: Build and test hassfest image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
attestations: write # For build provenance attestation
|
||||
id-token: write # For build provenance attestation
|
||||
needs: ["init"]
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
env:
|
||||
HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
load: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
|
||||
- name: Run hassfest against core
|
||||
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
68
.github/workflows/ci.yaml
vendored
68
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.4"
|
||||
HA_SHORT_VERSION: "2026.5"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
run: |
|
||||
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
|
||||
- name: Filter for core changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: core
|
||||
with:
|
||||
filters: .core_files.yaml
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
echo "Result:"
|
||||
cat .integration_paths.yaml
|
||||
- name: Filter for integration changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: integrations
|
||||
with:
|
||||
filters: .integration_paths.yaml
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
|
||||
with:
|
||||
extra-args: --all-files zizmor
|
||||
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
key: >-
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore uv wheel cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: >-
|
||||
@@ -384,7 +384,7 @@ jobs:
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -484,7 +484,7 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -515,7 +515,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -552,7 +552,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -643,7 +643,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -694,7 +694,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -747,7 +747,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -804,7 +804,7 @@ jobs:
|
||||
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -812,7 +812,7 @@ jobs:
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore mypy cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
@@ -854,7 +854,7 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -887,7 +887,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -930,7 +930,7 @@ jobs:
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -964,7 +964,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -978,7 +978,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- name: Compile English translations
|
||||
@@ -1080,7 +1080,7 @@ jobs:
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1115,7 +1115,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1238,7 +1238,7 @@ jobs:
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1275,7 +1275,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1387,12 +1387,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1421,7 +1421,7 @@ jobs:
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1455,7 +1455,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1558,12 +1558,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1587,11 +1587,11 @@ jobs:
|
||||
&& needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
10
.github/workflows/wheels.yml
vendored
10
.github/workflows/wheels.yml
vendored
@@ -121,12 +121,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
@@ -172,17 +172,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ homeassistant.components.calendar.*
|
||||
homeassistant.components.cambridge_audio.*
|
||||
homeassistant.components.camera.*
|
||||
homeassistant.components.canary.*
|
||||
homeassistant.components.casper_glow.*
|
||||
homeassistant.components.cert_expiry.*
|
||||
homeassistant.components.clickatell.*
|
||||
homeassistant.components.clicksend.*
|
||||
@@ -173,6 +174,7 @@ homeassistant.components.dnsip.*
|
||||
homeassistant.components.doorbird.*
|
||||
homeassistant.components.dormakaba_dkey.*
|
||||
homeassistant.components.downloader.*
|
||||
homeassistant.components.dropbox.*
|
||||
homeassistant.components.droplet.*
|
||||
homeassistant.components.dsmr.*
|
||||
homeassistant.components.duckdns.*
|
||||
@@ -272,10 +274,12 @@ homeassistant.components.homekit_controller.storage
|
||||
homeassistant.components.homekit_controller.utils
|
||||
homeassistant.components.homewizard.*
|
||||
homeassistant.components.homeworks.*
|
||||
homeassistant.components.hr_energy_qube.*
|
||||
homeassistant.components.http.*
|
||||
homeassistant.components.huawei_lte.*
|
||||
homeassistant.components.humidifier.*
|
||||
homeassistant.components.husqvarna_automower.*
|
||||
homeassistant.components.huum.*
|
||||
homeassistant.components.hydrawise.*
|
||||
homeassistant.components.hyperion.*
|
||||
homeassistant.components.hypontech.*
|
||||
@@ -325,6 +329,7 @@ homeassistant.components.ld2410_ble.*
|
||||
homeassistant.components.led_ble.*
|
||||
homeassistant.components.lektrico.*
|
||||
homeassistant.components.letpot.*
|
||||
homeassistant.components.lg_infrared.*
|
||||
homeassistant.components.libre_hardware_monitor.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.lifx.*
|
||||
|
||||
40
CODEOWNERS
generated
40
CODEOWNERS
generated
@@ -214,6 +214,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/balboa/ @garbled1 @natekspencer
|
||||
/homeassistant/components/bang_olufsen/ @mj23000
|
||||
/tests/components/bang_olufsen/ @mj23000
|
||||
/homeassistant/components/battery/ @home-assistant/core
|
||||
/tests/components/battery/ @home-assistant/core
|
||||
/homeassistant/components/bayesian/ @HarvsG
|
||||
/tests/components/bayesian/ @HarvsG
|
||||
/homeassistant/components/beewi_smartclim/ @alemuro
|
||||
@@ -273,6 +275,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/cambridge_audio/ @noahhusby
|
||||
/homeassistant/components/camera/ @home-assistant/core
|
||||
/tests/components/camera/ @home-assistant/core
|
||||
/homeassistant/components/casper_glow/ @mikeodr
|
||||
/tests/components/casper_glow/ @mikeodr
|
||||
/homeassistant/components/cast/ @emontnemery
|
||||
/tests/components/cast/ @emontnemery
|
||||
/homeassistant/components/ccm15/ @ocalvo
|
||||
@@ -397,6 +401,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dremel_3d_printer/ @tkdrob
|
||||
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/homeassistant/components/dropbox/ @bdr99
|
||||
/tests/components/dropbox/ @bdr99
|
||||
/homeassistant/components/droplet/ @sarahseidman
|
||||
/tests/components/droplet/ @sarahseidman
|
||||
/homeassistant/components/dsmr/ @Robbie1221
|
||||
@@ -733,6 +739,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/homewizard/ @DCSBL
|
||||
/homeassistant/components/honeywell/ @rdfurman @mkmer
|
||||
/tests/components/honeywell/ @rdfurman @mkmer
|
||||
/homeassistant/components/hr_energy_qube/ @MattieGit
|
||||
/tests/components/hr_energy_qube/ @MattieGit
|
||||
/homeassistant/components/html5/ @alexyao2015
|
||||
/tests/components/html5/ @alexyao2015
|
||||
/homeassistant/components/http/ @home-assistant/core
|
||||
@@ -780,6 +788,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/igloohome/ @keithle888
|
||||
/homeassistant/components/ign_sismologia/ @exxamalte
|
||||
/tests/components/ign_sismologia/ @exxamalte
|
||||
/homeassistant/components/illuminance/ @home-assistant/core
|
||||
/tests/components/illuminance/ @home-assistant/core
|
||||
/homeassistant/components/image/ @home-assistant/core
|
||||
/tests/components/image/ @home-assistant/core
|
||||
/homeassistant/components/image_processing/ @home-assistant/core
|
||||
@@ -939,12 +949,16 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/lektrico/ @lektrico
|
||||
/homeassistant/components/letpot/ @jpelgrom
|
||||
/tests/components/letpot/ @jpelgrom
|
||||
/homeassistant/components/lg_infrared/ @home-assistant/core
|
||||
/tests/components/lg_infrared/ @home-assistant/core
|
||||
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
||||
/tests/components/libre_hardware_monitor/ @Sab44
|
||||
/homeassistant/components/lichess/ @aryanhasgithub
|
||||
/tests/components/lichess/ @aryanhasgithub
|
||||
/homeassistant/components/lidarr/ @tkdrob
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/liebherr/ @mettolen
|
||||
@@ -1065,6 +1079,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/modern_forms/ @wonderslug
|
||||
/homeassistant/components/moehlenhoff_alpha2/ @j-a-n
|
||||
/tests/components/moehlenhoff_alpha2/ @j-a-n
|
||||
/homeassistant/components/moisture/ @home-assistant/core
|
||||
/tests/components/moisture/ @home-assistant/core
|
||||
/homeassistant/components/monarch_money/ @jeeftor
|
||||
/tests/components/monarch_money/ @jeeftor
|
||||
/homeassistant/components/monoprice/ @etsinko @OnFreund
|
||||
@@ -1212,8 +1228,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/onewire/ @garbled1 @epenet
|
||||
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
|
||||
/tests/components/onkyo/ @arturpragacz @eclair4151
|
||||
/homeassistant/components/onvif/ @hunterjm @jterrace
|
||||
/tests/components/onvif/ @hunterjm @jterrace
|
||||
/homeassistant/components/onvif/ @jterrace
|
||||
/tests/components/onvif/ @jterrace
|
||||
/homeassistant/components/open_meteo/ @frenck
|
||||
/tests/components/open_meteo/ @frenck
|
||||
/homeassistant/components/open_router/ @joostlek
|
||||
@@ -1303,6 +1319,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/poolsense/ @haemishkyd
|
||||
/homeassistant/components/portainer/ @erwindouna
|
||||
/tests/components/portainer/ @erwindouna
|
||||
/homeassistant/components/power/ @home-assistant/core
|
||||
/tests/components/power/ @home-assistant/core
|
||||
/homeassistant/components/powerfox/ @klaasnicolaas
|
||||
/tests/components/powerfox/ @klaasnicolaas
|
||||
/homeassistant/components/powerfox_local/ @klaasnicolaas
|
||||
@@ -1561,8 +1579,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sma/ @kellerza @rklomp @erwindouna
|
||||
/homeassistant/components/smappee/ @bsmappee
|
||||
/tests/components/smappee/ @bsmappee
|
||||
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
|
||||
/tests/components/smarla/ @explicatis @rlint-explicatis
|
||||
/homeassistant/components/smarla/ @explicatis @johannes-exp
|
||||
/tests/components/smarla/ @explicatis @johannes-exp
|
||||
/homeassistant/components/smart_meter_texas/ @grahamwetzler
|
||||
/tests/components/smart_meter_texas/ @grahamwetzler
|
||||
/homeassistant/components/smartthings/ @joostlek
|
||||
@@ -1588,6 +1606,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/solaredge_local/ @drobtravels @scheric
|
||||
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
|
||||
/tests/components/solarlog/ @Ernst79 @dontinelli
|
||||
/homeassistant/components/solarman/ @solarmanpv
|
||||
/tests/components/solarman/ @solarmanpv
|
||||
/homeassistant/components/solax/ @squishykid @Darsstar
|
||||
/tests/components/solax/ @squishykid @Darsstar
|
||||
/homeassistant/components/soma/ @ratsept
|
||||
@@ -1616,8 +1636,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/srp_energy/ @briglx
|
||||
/homeassistant/components/starline/ @anonym-tsk
|
||||
/tests/components/starline/ @anonym-tsk
|
||||
/homeassistant/components/starlink/ @boswelja
|
||||
/tests/components/starlink/ @boswelja
|
||||
/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
|
||||
/tests/components/statistics/ @ThomDietrich @gjohansson-ST
|
||||
/homeassistant/components/steam_online/ @tkdrob
|
||||
@@ -1699,6 +1717,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/teltonika/ @karlbeecken
|
||||
/tests/components/teltonika/ @karlbeecken
|
||||
/homeassistant/components/temperature/ @home-assistant/core
|
||||
/tests/components/temperature/ @home-assistant/core
|
||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||
/tests/components/template/ @Petro31 @home-assistant/core
|
||||
/homeassistant/components/tesla_fleet/ @Bre77
|
||||
@@ -1744,6 +1764,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tomorrowio/ @raman325 @lymanepp
|
||||
/homeassistant/components/totalconnect/ @austinmroczek
|
||||
/tests/components/totalconnect/ @austinmroczek
|
||||
/homeassistant/components/touchline/ @mnordseth
|
||||
/tests/components/touchline/ @mnordseth
|
||||
/homeassistant/components/touchline_sl/ @jnsgruk
|
||||
/tests/components/touchline_sl/ @jnsgruk
|
||||
/homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696
|
||||
@@ -1831,8 +1853,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vegehub/ @thulrus
|
||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||
/tests/components/velbus/ @Cereal2nd @brefra
|
||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
|
||||
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
|
||||
/homeassistant/components/velux/ @Julius2342 @pawlizio @wollew
|
||||
/tests/components/velux/ @Julius2342 @pawlizio @wollew
|
||||
/homeassistant/components/venstar/ @garbled1 @jhollowe
|
||||
/tests/components/venstar/ @garbled1 @jhollowe
|
||||
/homeassistant/components/versasense/ @imstevenxyz
|
||||
@@ -1915,6 +1937,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/whois/ @frenck
|
||||
/homeassistant/components/wiffi/ @mampfes
|
||||
/tests/components/wiffi/ @mampfes
|
||||
/homeassistant/components/wiim/ @Linkplay2020
|
||||
/tests/components/wiim/ @Linkplay2020
|
||||
/homeassistant/components/wilight/ @leofig-rj
|
||||
/tests/components/wilight/ @leofig-rj
|
||||
/homeassistant/components/window/ @home-assistant/core
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -29,7 +29,7 @@ RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
# Install uv
|
||||
&& pip3 install uv==0.10.6
|
||||
&& pip3 install uv==0.11.1
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -241,12 +241,18 @@ DEFAULT_INTEGRATIONS = {
|
||||
*BASE_PLATFORMS,
|
||||
#
|
||||
# Integrations providing triggers and conditions for base platforms:
|
||||
"air_quality",
|
||||
"battery",
|
||||
"door",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"moisture",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"power",
|
||||
"temperature",
|
||||
"window",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
@@ -462,6 +468,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
|
||||
translation.async_setup(hass)
|
||||
|
||||
recovery = hass.config.recovery_mode
|
||||
device_registry.async_setup(hass)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"domain": "lg",
|
||||
"name": "LG",
|
||||
"integrations": ["lg_netcast", "lg_soundbar", "lg_thinq", "webostv"]
|
||||
"integrations": [
|
||||
"lg_infrared",
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
"webostv"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
},
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["jaraco.abode", "lomond"],
|
||||
"requirements": ["jaraco.abode==6.2.1"],
|
||||
"requirements": ["jaraco.abode==6.4.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
|
||||
@@ -40,6 +41,7 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
|
||||
AbodeSensorDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
|
||||
device.temp_unit
|
||||
],
|
||||
@@ -48,12 +50,14 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
|
||||
AbodeSensorDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement_fn=lambda _: PERCENTAGE,
|
||||
value_fn=lambda device: cast(float, device.humidity),
|
||||
),
|
||||
AbodeSensorDescription(
|
||||
key="lux",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
|
||||
value_fn=lambda device: cast(float, device.lux),
|
||||
),
|
||||
|
||||
@@ -120,7 +120,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="timeout",
|
||||
)
|
||||
del self.login_task
|
||||
self.login_task = None
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reauth(
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/actron_air",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.4.1"]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
134
homeassistant/components/air_quality/condition.py
Normal file
134
homeassistant/components/air_quality/condition.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Provides conditions for air quality."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
make_entity_numerical_condition,
|
||||
make_entity_numerical_condition_with_unit,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
from homeassistant.util.unit_conversion import (
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
OzoneConcentrationConverter,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
UnitlessRatioConverter,
|
||||
)
|
||||
|
||||
|
||||
def _make_detected_condition(
|
||||
device_class: BinarySensorDeviceClass,
|
||||
) -> type[Condition]:
|
||||
"""Create a detected condition for a binary sensor device class."""
|
||||
return make_entity_state_condition(
|
||||
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
|
||||
)
|
||||
|
||||
|
||||
def _make_cleared_condition(
|
||||
device_class: BinarySensorDeviceClass,
|
||||
) -> type[Condition]:
|
||||
"""Create a cleared condition for a binary sensor device class."""
|
||||
return make_entity_state_condition(
|
||||
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
|
||||
)
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
# Binary sensor conditions (detected/cleared)
|
||||
"is_gas_detected": _make_detected_condition(BinarySensorDeviceClass.GAS),
|
||||
"is_gas_cleared": _make_cleared_condition(BinarySensorDeviceClass.GAS),
|
||||
"is_co_detected": _make_detected_condition(BinarySensorDeviceClass.CO),
|
||||
"is_co_cleared": _make_cleared_condition(BinarySensorDeviceClass.CO),
|
||||
"is_smoke_detected": _make_detected_condition(BinarySensorDeviceClass.SMOKE),
|
||||
"is_smoke_cleared": _make_cleared_condition(BinarySensorDeviceClass.SMOKE),
|
||||
# Numerical sensor conditions with unit conversion
|
||||
"is_co_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"is_ozone_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"is_voc_value": make_entity_numerical_condition_with_unit(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
),
|
||||
"is_voc_ratio_value": make_entity_numerical_condition_with_unit(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
),
|
||||
"is_no_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
),
|
||||
"is_no2_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
),
|
||||
"is_so2_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
),
|
||||
# Numerical sensor conditions without unit conversion (single-unit device classes)
|
||||
"is_co2_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
|
||||
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
"is_pm1_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"is_pm25_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"is_pm4_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"is_pm10_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"is_n2o_value": make_entity_numerical_condition(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the air quality conditions."""
|
||||
return CONDITIONS
|
||||
449
homeassistant/components/air_quality/conditions.yaml
Normal file
449
homeassistant/components/air_quality/conditions.yaml
Normal file
@@ -0,0 +1,449 @@
|
||||
# --- Common condition fields ---
|
||||
|
||||
.condition_behavior: &condition_behavior
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
# --- Unit lists for multi-unit pollutants ---
|
||||
|
||||
.co_units: &co_units
|
||||
- "ppb"
|
||||
- "ppm"
|
||||
- "mg/m³"
|
||||
- "μg/m³"
|
||||
|
||||
.ozone_units: &ozone_units
|
||||
- "ppb"
|
||||
- "ppm"
|
||||
- "μg/m³"
|
||||
|
||||
.voc_units: &voc_units
|
||||
- "μg/m³"
|
||||
- "mg/m³"
|
||||
|
||||
.voc_ratio_units: &voc_ratio_units
|
||||
- "ppb"
|
||||
- "ppm"
|
||||
|
||||
.no_units: &no_units
|
||||
- "ppb"
|
||||
- "μg/m³"
|
||||
|
||||
.no2_units: &no2_units
|
||||
- "ppb"
|
||||
- "ppm"
|
||||
- "μg/m³"
|
||||
|
||||
.so2_units: &so2_units
|
||||
- "ppb"
|
||||
- "μg/m³"
|
||||
|
||||
# --- Entity filter anchors ---
|
||||
|
||||
.co_threshold_entity: &co_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *co_units
|
||||
- domain: sensor
|
||||
device_class: carbon_monoxide
|
||||
- domain: number
|
||||
device_class: carbon_monoxide
|
||||
|
||||
.co2_threshold_entity: &co2_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "ppm"
|
||||
- domain: sensor
|
||||
device_class: carbon_dioxide
|
||||
- domain: number
|
||||
device_class: carbon_dioxide
|
||||
|
||||
.pm1_threshold_entity: &pm1_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: pm1
|
||||
- domain: number
|
||||
device_class: pm1
|
||||
|
||||
.pm25_threshold_entity: &pm25_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: pm25
|
||||
- domain: number
|
||||
device_class: pm25
|
||||
|
||||
.pm4_threshold_entity: &pm4_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: pm4
|
||||
- domain: number
|
||||
device_class: pm4
|
||||
|
||||
.pm10_threshold_entity: &pm10_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: pm10
|
||||
- domain: number
|
||||
device_class: pm10
|
||||
|
||||
.ozone_threshold_entity: &ozone_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *ozone_units
|
||||
- domain: sensor
|
||||
device_class: ozone
|
||||
- domain: number
|
||||
device_class: ozone
|
||||
|
||||
.voc_threshold_entity: &voc_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *voc_units
|
||||
- domain: sensor
|
||||
device_class: volatile_organic_compounds
|
||||
- domain: number
|
||||
device_class: volatile_organic_compounds
|
||||
|
||||
.voc_ratio_threshold_entity: &voc_ratio_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *voc_ratio_units
|
||||
- domain: sensor
|
||||
device_class: volatile_organic_compounds_parts
|
||||
- domain: number
|
||||
device_class: volatile_organic_compounds_parts
|
||||
|
||||
.no_threshold_entity: &no_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *no_units
|
||||
- domain: sensor
|
||||
device_class: nitrogen_monoxide
|
||||
- domain: number
|
||||
device_class: nitrogen_monoxide
|
||||
|
||||
.no2_threshold_entity: &no2_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *no2_units
|
||||
- domain: sensor
|
||||
device_class: nitrogen_dioxide
|
||||
- domain: number
|
||||
device_class: nitrogen_dioxide
|
||||
|
||||
.n2o_threshold_entity: &n2o_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: nitrous_oxide
|
||||
- domain: number
|
||||
device_class: nitrous_oxide
|
||||
|
||||
.so2_threshold_entity: &so2_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *so2_units
|
||||
- domain: sensor
|
||||
device_class: sulphur_dioxide
|
||||
- domain: number
|
||||
device_class: sulphur_dioxide
|
||||
|
||||
# --- Number anchors for single-unit pollutants ---
|
||||
|
||||
.co2_threshold_number: &co2_threshold_number
|
||||
mode: box
|
||||
unit_of_measurement: "ppm"
|
||||
|
||||
.ugm3_threshold_number: &ugm3_threshold_number
|
||||
mode: box
|
||||
unit_of_measurement: "μg/m³"
|
||||
|
||||
# --- Binary sensor targets ---
|
||||
|
||||
.target_gas: &target_gas
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: gas
|
||||
|
||||
.target_co_binary: &target_co_binary
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: carbon_monoxide
|
||||
|
||||
.target_smoke: &target_smoke
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: smoke
|
||||
|
||||
# --- Sensor targets ---
|
||||
|
||||
.target_co_sensor: &target_co_sensor
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: carbon_monoxide
|
||||
|
||||
.target_co2: &target_co2
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: carbon_dioxide
|
||||
|
||||
.target_pm1: &target_pm1
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: pm1
|
||||
|
||||
.target_pm25: &target_pm25
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: pm25
|
||||
|
||||
.target_pm4: &target_pm4
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: pm4
|
||||
|
||||
.target_pm10: &target_pm10
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: pm10
|
||||
|
||||
.target_ozone: &target_ozone
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: ozone
|
||||
|
||||
.target_voc: &target_voc
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: volatile_organic_compounds
|
||||
|
||||
.target_voc_ratio: &target_voc_ratio
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: volatile_organic_compounds_parts
|
||||
|
||||
.target_no: &target_no
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: nitrogen_monoxide
|
||||
|
||||
.target_no2: &target_no2
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: nitrogen_dioxide
|
||||
|
||||
.target_n2o: &target_n2o
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: nitrous_oxide
|
||||
|
||||
.target_so2: &target_so2
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: sulphur_dioxide
|
||||
|
||||
# --- Binary sensor conditions ---
|
||||
|
||||
.condition_binary_common: &condition_binary_common
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
|
||||
is_gas_detected:
|
||||
<<: *condition_binary_common
|
||||
target: *target_gas
|
||||
|
||||
is_gas_cleared:
|
||||
<<: *condition_binary_common
|
||||
target: *target_gas
|
||||
|
||||
is_co_detected:
|
||||
<<: *condition_binary_common
|
||||
target: *target_co_binary
|
||||
|
||||
is_co_cleared:
|
||||
<<: *condition_binary_common
|
||||
target: *target_co_binary
|
||||
|
||||
is_smoke_detected:
|
||||
<<: *condition_binary_common
|
||||
target: *target_smoke
|
||||
|
||||
is_smoke_cleared:
|
||||
<<: *condition_binary_common
|
||||
target: *target_smoke
|
||||
|
||||
# --- Numerical sensor conditions with unit conversion ---
|
||||
|
||||
is_co_value:
|
||||
target: *target_co_sensor
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *co_threshold_entity
|
||||
mode: is
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *co_units
|
||||
|
||||
is_ozone_value:
|
||||
target: *target_ozone
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *ozone_threshold_entity
|
||||
mode: is
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *ozone_units
|
||||
|
||||
is_voc_value:
|
||||
target: *target_voc
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *voc_threshold_entity
|
||||
mode: is
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *voc_units
|
||||
|
||||
is_voc_ratio_value:
|
||||
target: *target_voc_ratio
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *voc_ratio_threshold_entity
|
||||
mode: is
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *voc_ratio_units
|
||||
|
||||
is_no_value:
|
||||
target: *target_no
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *no_threshold_entity
|
||||
mode: is
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *no_units
|
||||
|
||||
is_no2_value:
|
||||
target: *target_no2
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *no2_threshold_entity
|
||||
mode: is
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *no2_units
|
||||
|
||||
is_so2_value:
|
||||
target: *target_so2
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *so2_threshold_entity
|
||||
mode: is
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *so2_units
|
||||
|
||||
# --- Numerical sensor conditions without unit conversion ---
|
||||
|
||||
is_co2_value:
|
||||
target: *target_co2
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *co2_threshold_entity
|
||||
mode: is
|
||||
number: *co2_threshold_number
|
||||
|
||||
is_pm1_value:
|
||||
target: *target_pm1
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm1_threshold_entity
|
||||
mode: is
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
is_pm25_value:
|
||||
target: *target_pm25
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm25_threshold_entity
|
||||
mode: is
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
is_pm4_value:
|
||||
target: *target_pm4
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm4_threshold_entity
|
||||
mode: is
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
is_pm10_value:
|
||||
target: *target_pm10
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm10_threshold_entity
|
||||
mode: is
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
is_n2o_value:
|
||||
target: *target_n2o
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *n2o_threshold_entity
|
||||
mode: is
|
||||
number: *ugm3_threshold_number
|
||||
@@ -1,7 +1,164 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_co2_value": {
|
||||
"condition": "mdi:molecule-co2"
|
||||
},
|
||||
"is_co_cleared": {
|
||||
"condition": "mdi:check-circle"
|
||||
},
|
||||
"is_co_detected": {
|
||||
"condition": "mdi:molecule-co"
|
||||
},
|
||||
"is_co_value": {
|
||||
"condition": "mdi:molecule-co"
|
||||
},
|
||||
"is_gas_cleared": {
|
||||
"condition": "mdi:check-circle"
|
||||
},
|
||||
"is_gas_detected": {
|
||||
"condition": "mdi:gas-cylinder"
|
||||
},
|
||||
"is_n2o_value": {
|
||||
"condition": "mdi:factory"
|
||||
},
|
||||
"is_no2_value": {
|
||||
"condition": "mdi:factory"
|
||||
},
|
||||
"is_no_value": {
|
||||
"condition": "mdi:factory"
|
||||
},
|
||||
"is_ozone_value": {
|
||||
"condition": "mdi:weather-sunny-alert"
|
||||
},
|
||||
"is_pm10_value": {
|
||||
"condition": "mdi:blur"
|
||||
},
|
||||
"is_pm1_value": {
|
||||
"condition": "mdi:blur"
|
||||
},
|
||||
"is_pm25_value": {
|
||||
"condition": "mdi:blur"
|
||||
},
|
||||
"is_pm4_value": {
|
||||
"condition": "mdi:blur"
|
||||
},
|
||||
"is_smoke_cleared": {
|
||||
"condition": "mdi:check-circle"
|
||||
},
|
||||
"is_smoke_detected": {
|
||||
"condition": "mdi:smoke-detector-variant"
|
||||
},
|
||||
"is_so2_value": {
|
||||
"condition": "mdi:factory"
|
||||
},
|
||||
"is_voc_ratio_value": {
|
||||
"condition": "mdi:air-filter"
|
||||
},
|
||||
"is_voc_value": {
|
||||
"condition": "mdi:air-filter"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:air-filter"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"co2_changed": {
|
||||
"trigger": "mdi:molecule-co2"
|
||||
},
|
||||
"co2_crossed_threshold": {
|
||||
"trigger": "mdi:molecule-co2"
|
||||
},
|
||||
"co_changed": {
|
||||
"trigger": "mdi:molecule-co"
|
||||
},
|
||||
"co_cleared": {
|
||||
"trigger": "mdi:check-circle"
|
||||
},
|
||||
"co_crossed_threshold": {
|
||||
"trigger": "mdi:molecule-co"
|
||||
},
|
||||
"co_detected": {
|
||||
"trigger": "mdi:molecule-co"
|
||||
},
|
||||
"gas_cleared": {
|
||||
"trigger": "mdi:check-circle"
|
||||
},
|
||||
"gas_detected": {
|
||||
"trigger": "mdi:gas-cylinder"
|
||||
},
|
||||
"n2o_changed": {
|
||||
"trigger": "mdi:factory"
|
||||
},
|
||||
"n2o_crossed_threshold": {
|
||||
"trigger": "mdi:factory"
|
||||
},
|
||||
"no2_changed": {
|
||||
"trigger": "mdi:factory"
|
||||
},
|
||||
"no2_crossed_threshold": {
|
||||
"trigger": "mdi:factory"
|
||||
},
|
||||
"no_changed": {
|
||||
"trigger": "mdi:factory"
|
||||
},
|
||||
"no_crossed_threshold": {
|
||||
"trigger": "mdi:factory"
|
||||
},
|
||||
"ozone_changed": {
|
||||
"trigger": "mdi:weather-sunny-alert"
|
||||
},
|
||||
"ozone_crossed_threshold": {
|
||||
"trigger": "mdi:weather-sunny-alert"
|
||||
},
|
||||
"pm10_changed": {
|
||||
"trigger": "mdi:blur"
|
||||
},
|
||||
"pm10_crossed_threshold": {
|
||||
"trigger": "mdi:blur"
|
||||
},
|
||||
"pm1_changed": {
|
||||
"trigger": "mdi:blur"
|
||||
},
|
||||
"pm1_crossed_threshold": {
|
||||
"trigger": "mdi:blur"
|
||||
},
|
||||
"pm25_changed": {
|
||||
"trigger": "mdi:blur"
|
||||
},
|
||||
"pm25_crossed_threshold": {
|
||||
"trigger": "mdi:blur"
|
||||
},
|
||||
"pm4_changed": {
|
||||
"trigger": "mdi:blur"
|
||||
},
|
||||
"pm4_crossed_threshold": {
|
||||
"trigger": "mdi:blur"
|
||||
},
|
||||
"smoke_cleared": {
|
||||
"trigger": "mdi:check-circle"
|
||||
},
|
||||
"smoke_detected": {
|
||||
"trigger": "mdi:smoke-detector-variant"
|
||||
},
|
||||
"so2_changed": {
|
||||
"trigger": "mdi:factory"
|
||||
},
|
||||
"so2_crossed_threshold": {
|
||||
"trigger": "mdi:factory"
|
||||
},
|
||||
"voc_changed": {
|
||||
"trigger": "mdi:air-filter"
|
||||
},
|
||||
"voc_crossed_threshold": {
|
||||
"trigger": "mdi:air-filter"
|
||||
},
|
||||
"voc_ratio_changed": {
|
||||
"trigger": "mdi:air-filter"
|
||||
},
|
||||
"voc_ratio_crossed_threshold": {
|
||||
"trigger": "mdi:air-filter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
647
homeassistant/components/air_quality/strings.json
Normal file
647
homeassistant/components/air_quality/strings.json
Normal file
@@ -0,0 +1,647 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the value should match on the targeted entities.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"condition_threshold_description": "What to test for and threshold values.",
|
||||
"condition_threshold_name": "Threshold configuration",
|
||||
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
|
||||
"trigger_behavior_name": "Behavior",
|
||||
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
|
||||
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
|
||||
"trigger_threshold_name": "Threshold configuration"
|
||||
},
|
||||
"conditions": {
|
||||
"is_co2_value": {
|
||||
"description": "Tests the carbon dioxide level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon dioxide value"
|
||||
},
|
||||
"is_co_cleared": {
|
||||
"description": "Tests if one or more carbon monoxide sensors are cleared.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon monoxide cleared"
|
||||
},
|
||||
"is_co_detected": {
|
||||
"description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon monoxide detected"
|
||||
},
|
||||
"is_co_value": {
|
||||
"description": "Tests the carbon monoxide level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon monoxide value"
|
||||
},
|
||||
"is_gas_cleared": {
|
||||
"description": "Tests if one or more gas sensors are cleared.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gas cleared"
|
||||
},
|
||||
"is_gas_detected": {
|
||||
"description": "Tests if one or more gas sensors are detecting gas.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gas detected"
|
||||
},
|
||||
"is_n2o_value": {
|
||||
"description": "Tests the nitrous oxide level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrous oxide value"
|
||||
},
|
||||
"is_no2_value": {
|
||||
"description": "Tests the nitrogen dioxide level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrogen dioxide value"
|
||||
},
|
||||
"is_no_value": {
|
||||
"description": "Tests the nitrogen monoxide level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrogen monoxide value"
|
||||
},
|
||||
"is_ozone_value": {
|
||||
"description": "Tests the ozone level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Ozone value"
|
||||
},
|
||||
"is_pm10_value": {
|
||||
"description": "Tests the PM10 level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM10 value"
|
||||
},
|
||||
"is_pm1_value": {
|
||||
"description": "Tests the PM1 level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM1 value"
|
||||
},
|
||||
"is_pm25_value": {
|
||||
"description": "Tests the PM2.5 level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM2.5 value"
|
||||
},
|
||||
"is_pm4_value": {
|
||||
"description": "Tests the PM4 level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM4 value"
|
||||
},
|
||||
"is_smoke_cleared": {
|
||||
"description": "Tests if one or more smoke sensors are cleared.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Smoke cleared"
|
||||
},
|
||||
"is_smoke_detected": {
|
||||
"description": "Tests if one or more smoke sensors are detecting smoke.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Smoke detected"
|
||||
},
|
||||
"is_so2_value": {
|
||||
"description": "Tests the sulphur dioxide level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Sulphur dioxide value"
|
||||
},
|
||||
"is_voc_ratio_value": {
|
||||
"description": "Tests the volatile organic compounds ratio of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Volatile organic compounds ratio value"
|
||||
},
|
||||
"is_voc_value": {
|
||||
"description": "Tests the volatile organic compounds level of one or more entities.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Volatile organic compounds value"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Air Quality",
|
||||
"triggers": {
|
||||
"co2_changed": {
|
||||
"description": "Triggers after one or more carbon dioxide levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon dioxide level changed"
|
||||
},
|
||||
"co2_crossed_threshold": {
|
||||
"description": "Triggers after one or more carbon dioxide levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon dioxide level crossed threshold"
|
||||
},
|
||||
"co_changed": {
|
||||
"description": "Triggers after one or more carbon monoxide levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon monoxide level changed"
|
||||
},
|
||||
"co_cleared": {
|
||||
"description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon monoxide cleared"
|
||||
},
|
||||
"co_crossed_threshold": {
|
||||
"description": "Triggers after one or more carbon monoxide levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon monoxide level crossed threshold"
|
||||
},
|
||||
"co_detected": {
|
||||
"description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Carbon monoxide detected"
|
||||
},
|
||||
"gas_cleared": {
|
||||
"description": "Triggers after one or more gas sensors stop detecting gas.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gas cleared"
|
||||
},
|
||||
"gas_detected": {
|
||||
"description": "Triggers after one or more gas sensors start detecting gas.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gas detected"
|
||||
},
|
||||
"n2o_changed": {
|
||||
"description": "Triggers after one or more nitrous oxide levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrous oxide level changed"
|
||||
},
|
||||
"n2o_crossed_threshold": {
|
||||
"description": "Triggers after one or more nitrous oxide levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrous oxide level crossed threshold"
|
||||
},
|
||||
"no2_changed": {
|
||||
"description": "Triggers after one or more nitrogen dioxide levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrogen dioxide level changed"
|
||||
},
|
||||
"no2_crossed_threshold": {
|
||||
"description": "Triggers after one or more nitrogen dioxide levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrogen dioxide level crossed threshold"
|
||||
},
|
||||
"no_changed": {
|
||||
"description": "Triggers after one or more nitrogen monoxide levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrogen monoxide level changed"
|
||||
},
|
||||
"no_crossed_threshold": {
|
||||
"description": "Triggers after one or more nitrogen monoxide levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Nitrogen monoxide level crossed threshold"
|
||||
},
|
||||
"ozone_changed": {
|
||||
"description": "Triggers after one or more ozone levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Ozone level changed"
|
||||
},
|
||||
"ozone_crossed_threshold": {
|
||||
"description": "Triggers after one or more ozone levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Ozone level crossed threshold"
|
||||
},
|
||||
"pm10_changed": {
|
||||
"description": "Triggers after one or more PM10 levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM10 level changed"
|
||||
},
|
||||
"pm10_crossed_threshold": {
|
||||
"description": "Triggers after one or more PM10 levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM10 level crossed threshold"
|
||||
},
|
||||
"pm1_changed": {
|
||||
"description": "Triggers after one or more PM1 levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM1 level changed"
|
||||
},
|
||||
"pm1_crossed_threshold": {
|
||||
"description": "Triggers after one or more PM1 levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM1 level crossed threshold"
|
||||
},
|
||||
"pm25_changed": {
|
||||
"description": "Triggers after one or more PM2.5 levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM2.5 level changed"
|
||||
},
|
||||
"pm25_crossed_threshold": {
|
||||
"description": "Triggers after one or more PM2.5 levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM2.5 level crossed threshold"
|
||||
},
|
||||
"pm4_changed": {
|
||||
"description": "Triggers after one or more PM4 levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM4 level changed"
|
||||
},
|
||||
"pm4_crossed_threshold": {
|
||||
"description": "Triggers after one or more PM4 levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "PM4 level crossed threshold"
|
||||
},
|
||||
"smoke_cleared": {
|
||||
"description": "Triggers after one or more smoke sensors stop detecting smoke.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Smoke cleared"
|
||||
},
|
||||
"smoke_detected": {
|
||||
"description": "Triggers after one or more smoke sensors start detecting smoke.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Smoke detected"
|
||||
},
|
||||
"so2_changed": {
|
||||
"description": "Triggers after one or more sulphur dioxide levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Sulphur dioxide level changed"
|
||||
},
|
||||
"so2_crossed_threshold": {
|
||||
"description": "Triggers after one or more sulphur dioxide levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Sulphur dioxide level crossed threshold"
|
||||
},
|
||||
"voc_changed": {
|
||||
"description": "Triggers after one or more volatile organic compound levels change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Volatile organic compounds level changed"
|
||||
},
|
||||
"voc_crossed_threshold": {
|
||||
"description": "Triggers after one or more volatile organic compounds levels cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Volatile organic compounds level crossed threshold"
|
||||
},
|
||||
"voc_ratio_changed": {
|
||||
"description": "Triggers after one or more volatile organic compound ratios change.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Volatile organic compounds ratio changed"
|
||||
},
|
||||
"voc_ratio_crossed_threshold": {
|
||||
"description": "Triggers after one or more volatile organic compounds ratios cross a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Volatile organic compounds ratio crossed threshold"
|
||||
}
|
||||
}
|
||||
}
|
||||
206
homeassistant/components/air_quality/trigger.py
Normal file
206
homeassistant/components/air_quality/trigger.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Provides triggers for air quality."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_changed_with_unit_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
from homeassistant.util.unit_conversion import (
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
OzoneConcentrationConverter,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
UnitlessRatioConverter,
|
||||
)
|
||||
|
||||
|
||||
def _make_detected_trigger(
|
||||
device_class: BinarySensorDeviceClass,
|
||||
) -> type[EntityTargetStateTriggerBase]:
|
||||
"""Create a detected trigger for a binary sensor device class."""
|
||||
|
||||
return make_entity_target_state_trigger(
|
||||
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
|
||||
)
|
||||
|
||||
|
||||
def _make_cleared_trigger(
|
||||
device_class: BinarySensorDeviceClass,
|
||||
) -> type[EntityTargetStateTriggerBase]:
|
||||
"""Create a cleared trigger for a binary sensor device class."""
|
||||
|
||||
return make_entity_target_state_trigger(
|
||||
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
# Binary sensor triggers (detected/cleared)
|
||||
"gas_detected": _make_detected_trigger(BinarySensorDeviceClass.GAS),
|
||||
"gas_cleared": _make_cleared_trigger(BinarySensorDeviceClass.GAS),
|
||||
"co_detected": _make_detected_trigger(BinarySensorDeviceClass.CO),
|
||||
"co_cleared": _make_cleared_trigger(BinarySensorDeviceClass.CO),
|
||||
"smoke_detected": _make_detected_trigger(BinarySensorDeviceClass.SMOKE),
|
||||
"smoke_cleared": _make_cleared_trigger(BinarySensorDeviceClass.SMOKE),
|
||||
# Numerical sensor triggers with unit conversion
|
||||
"co_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
),
|
||||
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
),
|
||||
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
),
|
||||
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
),
|
||||
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
),
|
||||
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
),
|
||||
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
),
|
||||
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
),
|
||||
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
),
|
||||
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
),
|
||||
# Numerical sensor triggers without unit conversion (single-unit device classes)
|
||||
"co2_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
|
||||
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
"co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
|
||||
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
"pm1_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm25_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm4_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm10_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"n2o_changed": make_entity_numerical_state_changed_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
"n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
|
||||
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for air quality."""
|
||||
return TRIGGERS
|
||||
617
homeassistant/components/air_quality/triggers.yaml
Normal file
617
homeassistant/components/air_quality/triggers.yaml
Normal file
@@ -0,0 +1,617 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
# --- Unit lists for multi-unit pollutants ---
|
||||
|
||||
.co_units: &co_units
|
||||
- "ppb"
|
||||
- "ppm"
|
||||
- "mg/m³"
|
||||
- "μg/m³"
|
||||
|
||||
.ozone_units: &ozone_units
|
||||
- "ppb"
|
||||
- "ppm"
|
||||
- "μg/m³"
|
||||
|
||||
.voc_units: &voc_units
|
||||
- "μg/m³"
|
||||
- "mg/m³"
|
||||
|
||||
.voc_ratio_units: &voc_ratio_units
|
||||
- "ppb"
|
||||
- "ppm"
|
||||
|
||||
.no_units: &no_units
|
||||
- "ppb"
|
||||
- "μg/m³"
|
||||
|
||||
.no2_units: &no2_units
|
||||
- "ppb"
|
||||
- "ppm"
|
||||
- "μg/m³"
|
||||
|
||||
.so2_units: &so2_units
|
||||
- "ppb"
|
||||
- "μg/m³"
|
||||
|
||||
# --- Entity filter anchors ---
|
||||
|
||||
.co_threshold_entity: &co_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *co_units
|
||||
- domain: sensor
|
||||
device_class: carbon_monoxide
|
||||
- domain: number
|
||||
device_class: carbon_monoxide
|
||||
|
||||
.co2_threshold_entity: &co2_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "ppm"
|
||||
- domain: sensor
|
||||
device_class: carbon_dioxide
|
||||
- domain: number
|
||||
device_class: carbon_dioxide
|
||||
|
||||
.pm1_threshold_entity: &pm1_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: pm1
|
||||
- domain: number
|
||||
device_class: pm1
|
||||
|
||||
.pm25_threshold_entity: &pm25_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: pm25
|
||||
- domain: number
|
||||
device_class: pm25
|
||||
|
||||
.pm4_threshold_entity: &pm4_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: pm4
|
||||
- domain: number
|
||||
device_class: pm4
|
||||
|
||||
.pm10_threshold_entity: &pm10_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: pm10
|
||||
- domain: number
|
||||
device_class: pm10
|
||||
|
||||
.ozone_threshold_entity: &ozone_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *ozone_units
|
||||
- domain: sensor
|
||||
device_class: ozone
|
||||
- domain: number
|
||||
device_class: ozone
|
||||
|
||||
.voc_threshold_entity: &voc_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *voc_units
|
||||
- domain: sensor
|
||||
device_class: volatile_organic_compounds
|
||||
- domain: number
|
||||
device_class: volatile_organic_compounds
|
||||
|
||||
.voc_ratio_threshold_entity: &voc_ratio_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *voc_ratio_units
|
||||
- domain: sensor
|
||||
device_class: volatile_organic_compounds_parts
|
||||
- domain: number
|
||||
device_class: volatile_organic_compounds_parts
|
||||
|
||||
.no_threshold_entity: &no_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *no_units
|
||||
- domain: sensor
|
||||
device_class: nitrogen_monoxide
|
||||
- domain: number
|
||||
device_class: nitrogen_monoxide
|
||||
|
||||
.no2_threshold_entity: &no2_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *no2_units
|
||||
- domain: sensor
|
||||
device_class: nitrogen_dioxide
|
||||
- domain: number
|
||||
device_class: nitrogen_dioxide
|
||||
|
||||
.n2o_threshold_entity: &n2o_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "μg/m³"
|
||||
- domain: sensor
|
||||
device_class: nitrous_oxide
|
||||
- domain: number
|
||||
device_class: nitrous_oxide
|
||||
|
||||
.so2_threshold_entity: &so2_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: *so2_units
|
||||
- domain: sensor
|
||||
device_class: sulphur_dioxide
|
||||
- domain: number
|
||||
device_class: sulphur_dioxide
|
||||
|
||||
# --- Number anchors for single-unit pollutants ---
|
||||
|
||||
.co2_threshold_number: &co2_threshold_number
|
||||
mode: box
|
||||
unit_of_measurement: "ppm"
|
||||
|
||||
.ugm3_threshold_number: &ugm3_threshold_number
|
||||
mode: box
|
||||
unit_of_measurement: "μg/m³"
|
||||
|
||||
# Binary sensor detected/cleared trigger fields
|
||||
.trigger_binary_fields: &trigger_binary_fields
|
||||
behavior: *trigger_behavior
|
||||
|
||||
# --- Binary sensor targets ---
|
||||
|
||||
.target_gas: &target_gas
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: gas
|
||||
|
||||
.target_co_binary: &target_co_binary
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: carbon_monoxide
|
||||
|
||||
.target_smoke: &target_smoke
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: smoke
|
||||
|
||||
# --- Sensor targets ---
|
||||
|
||||
.target_co_sensor: &target_co_sensor
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: carbon_monoxide
|
||||
|
||||
.target_co2: &target_co2
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: carbon_dioxide
|
||||
|
||||
.target_pm1: &target_pm1
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: pm1
|
||||
|
||||
.target_pm25: &target_pm25
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: pm25
|
||||
|
||||
.target_pm4: &target_pm4
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: pm4
|
||||
|
||||
.target_pm10: &target_pm10
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: pm10
|
||||
|
||||
.target_ozone: &target_ozone
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: ozone
|
||||
|
||||
.target_voc: &target_voc
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: volatile_organic_compounds
|
||||
|
||||
.target_voc_ratio: &target_voc_ratio
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: volatile_organic_compounds_parts
|
||||
|
||||
.target_no: &target_no
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: nitrogen_monoxide
|
||||
|
||||
.target_no2: &target_no2
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: nitrogen_dioxide
|
||||
|
||||
.target_n2o: &target_n2o
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: nitrous_oxide
|
||||
|
||||
.target_so2: &target_so2
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: sulphur_dioxide
|
||||
|
||||
# --- Binary sensor triggers ---
|
||||
|
||||
gas_detected:
|
||||
fields: *trigger_binary_fields
|
||||
target: *target_gas
|
||||
|
||||
gas_cleared:
|
||||
fields: *trigger_binary_fields
|
||||
target: *target_gas
|
||||
|
||||
co_detected:
|
||||
fields: *trigger_binary_fields
|
||||
target: *target_co_binary
|
||||
|
||||
co_cleared:
|
||||
fields: *trigger_binary_fields
|
||||
target: *target_co_binary
|
||||
|
||||
smoke_detected:
|
||||
fields: *trigger_binary_fields
|
||||
target: *target_smoke
|
||||
|
||||
smoke_cleared:
|
||||
fields: *trigger_binary_fields
|
||||
target: *target_smoke
|
||||
|
||||
# --- Numerical sensor triggers ---
|
||||
|
||||
# CO (multi-unit)
|
||||
co_changed:
|
||||
target: *target_co_sensor
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *co_threshold_entity
|
||||
mode: changed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *co_units
|
||||
|
||||
co_crossed_threshold:
|
||||
target: *target_co_sensor
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *co_threshold_entity
|
||||
mode: crossed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *co_units
|
||||
|
||||
# CO2 (single-unit: ppm)
|
||||
co2_changed:
|
||||
target: *target_co2
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *co2_threshold_entity
|
||||
mode: changed
|
||||
number: *co2_threshold_number
|
||||
|
||||
co2_crossed_threshold:
|
||||
target: *target_co2
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *co2_threshold_entity
|
||||
mode: crossed
|
||||
number: *co2_threshold_number
|
||||
|
||||
# PM1 (single-unit: μg/m³)
|
||||
pm1_changed:
|
||||
target: *target_pm1
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm1_threshold_entity
|
||||
mode: changed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
pm1_crossed_threshold:
|
||||
target: *target_pm1
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm1_threshold_entity
|
||||
mode: crossed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
# PM2.5 (single-unit: μg/m³)
|
||||
pm25_changed:
|
||||
target: *target_pm25
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm25_threshold_entity
|
||||
mode: changed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
pm25_crossed_threshold:
|
||||
target: *target_pm25
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm25_threshold_entity
|
||||
mode: crossed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
# PM4 (single-unit: μg/m³)
|
||||
pm4_changed:
|
||||
target: *target_pm4
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm4_threshold_entity
|
||||
mode: changed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
pm4_crossed_threshold:
|
||||
target: *target_pm4
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm4_threshold_entity
|
||||
mode: crossed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
# PM10 (single-unit: μg/m³)
|
||||
pm10_changed:
|
||||
target: *target_pm10
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm10_threshold_entity
|
||||
mode: changed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
pm10_crossed_threshold:
|
||||
target: *target_pm10
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *pm10_threshold_entity
|
||||
mode: crossed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
# Ozone (multi-unit)
|
||||
ozone_changed:
|
||||
target: *target_ozone
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *ozone_threshold_entity
|
||||
mode: changed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *ozone_units
|
||||
|
||||
ozone_crossed_threshold:
|
||||
target: *target_ozone
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *ozone_threshold_entity
|
||||
mode: crossed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *ozone_units
|
||||
|
||||
# VOC (multi-unit)
|
||||
voc_changed:
|
||||
target: *target_voc
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *voc_threshold_entity
|
||||
mode: changed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *voc_units
|
||||
|
||||
voc_crossed_threshold:
|
||||
target: *target_voc
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *voc_threshold_entity
|
||||
mode: crossed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *voc_units
|
||||
|
||||
# VOC ratio (multi-unit)
|
||||
voc_ratio_changed:
|
||||
target: *target_voc_ratio
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *voc_ratio_threshold_entity
|
||||
mode: changed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *voc_ratio_units
|
||||
|
||||
voc_ratio_crossed_threshold:
|
||||
target: *target_voc_ratio
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *voc_ratio_threshold_entity
|
||||
mode: crossed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *voc_ratio_units
|
||||
|
||||
# NO (multi-unit)
|
||||
no_changed:
|
||||
target: *target_no
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *no_threshold_entity
|
||||
mode: changed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *no_units
|
||||
|
||||
no_crossed_threshold:
|
||||
target: *target_no
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *no_threshold_entity
|
||||
mode: crossed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *no_units
|
||||
|
||||
# NO2 (multi-unit)
|
||||
no2_changed:
|
||||
target: *target_no2
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *no2_threshold_entity
|
||||
mode: changed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *no2_units
|
||||
|
||||
no2_crossed_threshold:
|
||||
target: *target_no2
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *no2_threshold_entity
|
||||
mode: crossed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *no2_units
|
||||
|
||||
# N2O (single-unit: μg/m³)
|
||||
n2o_changed:
|
||||
target: *target_n2o
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *n2o_threshold_entity
|
||||
mode: changed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
n2o_crossed_threshold:
|
||||
target: *target_n2o
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *n2o_threshold_entity
|
||||
mode: crossed
|
||||
number: *ugm3_threshold_number
|
||||
|
||||
# SO2 (multi-unit)
|
||||
so2_changed:
|
||||
target: *target_so2
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *so2_threshold_entity
|
||||
mode: changed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *so2_units
|
||||
|
||||
so2_crossed_threshold:
|
||||
target: *target_so2
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *so2_threshold_entity
|
||||
mode: crossed
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: *so2_units
|
||||
@@ -87,7 +87,7 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("Successfully connected to %s", user_input[CONF_IP_ADDRESS])
|
||||
|
||||
device_info = await airq.fetch_device_info()
|
||||
await self.async_set_unique_id(device_info["id"])
|
||||
await self.async_set_unique_id(device_info["id"], raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
_LOGGER.debug("Creating an entry for %s", device_info["name"])
|
||||
|
||||
36
homeassistant/components/airq/diagnostics.py
Normal file
36
homeassistant/components/airq/diagnostics.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Diagnostics support for air-Q."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AirQConfigEntry
|
||||
|
||||
REDACT_CONFIG = {CONF_PASSWORD, CONF_UNIQUE_ID, CONF_IP_ADDRESS, "title"}
|
||||
REDACT_DEVICE_INFO = {"identifiers", "name"}
|
||||
REDACT_COORDINATOR_DATA = {"DeviceID"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AirQConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(entry.as_dict(), REDACT_CONFIG),
|
||||
"device_info": async_redact_data(
|
||||
dict(coordinator.device_info), REDACT_DEVICE_INFO
|
||||
),
|
||||
"coordinator_data": async_redact_data(
|
||||
coordinator.data, REDACT_COORDINATOR_DATA
|
||||
),
|
||||
"options": {
|
||||
"clip_negative": coordinator.clip_negative,
|
||||
"return_average": coordinator.return_average,
|
||||
},
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date."
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -13,6 +13,9 @@ from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
device_registry as dr,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||
@@ -25,11 +28,17 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Aladdin Connect Genie from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
@@ -46,19 +55,10 @@ async def async_setup_entry(
|
||||
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
|
||||
)
|
||||
|
||||
try:
|
||||
doors = await client.get_doors()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
coordinator = AladdinConnectCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = {
|
||||
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
|
||||
for door in doors
|
||||
}
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -100,7 +100,7 @@ def remove_stale_devices(
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry.entry_id
|
||||
)
|
||||
all_device_ids = set(config_entry.runtime_data)
|
||||
all_device_ids = set(config_entry.runtime_data.data)
|
||||
|
||||
for device_entry in device_entries:
|
||||
device_id: str | None = None
|
||||
|
||||
@@ -11,22 +11,24 @@ from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
|
||||
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
|
||||
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
class AladdinConnectCoordinator(DataUpdateCoordinator[dict[str, GarageDoor]]):
|
||||
"""Coordinator for Aladdin Connect integration."""
|
||||
|
||||
config_entry: AladdinConnectConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: AladdinConnectConfigEntry,
|
||||
client: AladdinConnectClient,
|
||||
garage_door: GarageDoor,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
@@ -37,18 +39,16 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
self.data = garage_door
|
||||
|
||||
async def _async_update_data(self) -> GarageDoor:
|
||||
async def _async_update_data(self) -> dict[str, GarageDoor]:
|
||||
"""Fetch data from the Aladdin Connect API."""
|
||||
try:
|
||||
await self.client.update_door(self.data.device_id, self.data.door_number)
|
||||
doors = await self.client.get_doors()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
self.data.status = self.client.get_door_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
self.data.battery_level = self.client.get_battery_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
return self.data
|
||||
|
||||
return {door.unique_id: door for door in doors}
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -24,11 +24,22 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the cover platform."""
|
||||
coordinators = entry.runtime_data
|
||||
coordinator = entry.runtime_data
|
||||
known_devices: set[str] = set()
|
||||
|
||||
async_add_entities(
|
||||
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
|
||||
)
|
||||
@callback
|
||||
def _async_add_new_devices() -> None:
|
||||
"""Detect and add entities for new doors."""
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AladdinCoverEntity(coordinator, door_id) for door_id in new_devices
|
||||
)
|
||||
|
||||
_async_add_new_devices()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
|
||||
|
||||
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
@@ -38,10 +49,10 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
_attr_supported_features = SUPPORTED_FEATURES
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
|
||||
"""Initialize the Aladdin Connect cover."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, door_id)
|
||||
self._attr_unique_id = door_id
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue open command to cover."""
|
||||
@@ -66,16 +77,16 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Update is closed attribute."""
|
||||
if (status := self.coordinator.data.status) is None:
|
||||
if (status := self.door.status) is None:
|
||||
return None
|
||||
return status == "closed"
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Update is closing attribute."""
|
||||
return self.coordinator.data.status == "closing"
|
||||
return self.door.status == "closing"
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Update is opening attribute."""
|
||||
return self.coordinator.data.status == "opening"
|
||||
return self.door.status == "opening"
|
||||
|
||||
@@ -20,13 +20,13 @@ async def async_get_config_entry_diagnostics(
|
||||
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
|
||||
"doors": {
|
||||
uid: {
|
||||
"device_id": coordinator.data.device_id,
|
||||
"door_number": coordinator.data.door_number,
|
||||
"name": coordinator.data.name,
|
||||
"status": coordinator.data.status,
|
||||
"link_status": coordinator.data.link_status,
|
||||
"battery_level": coordinator.data.battery_level,
|
||||
"device_id": door.device_id,
|
||||
"door_number": door.door_number,
|
||||
"name": door.name,
|
||||
"status": door.status,
|
||||
"link_status": door.link_status,
|
||||
"battery_level": door.battery_level,
|
||||
}
|
||||
for uid, coordinator in config_entry.runtime_data.items()
|
||||
for uid, door in config_entry.runtime_data.data.items()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Base class for Aladdin Connect entities."""
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -14,17 +15,28 @@ class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
|
||||
"""Initialize Aladdin Connect entity."""
|
||||
super().__init__(coordinator)
|
||||
device = coordinator.data
|
||||
self._door_id = door_id
|
||||
door = self.door
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
identifiers={(DOMAIN, door.unique_id)},
|
||||
manufacturer="Aladdin Connect",
|
||||
name=device.name,
|
||||
name=door.name,
|
||||
)
|
||||
self._device_id = device.device_id
|
||||
self._number = device.door_number
|
||||
self._device_id = door.device_id
|
||||
self._number = door.door_number
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._door_id in self.coordinator.data
|
||||
|
||||
@property
|
||||
def door(self) -> GarageDoor:
|
||||
"""Return the garage door data."""
|
||||
return self.coordinator.data[self._door_id]
|
||||
|
||||
@property
|
||||
def client(self) -> AladdinConnectClient:
|
||||
|
||||
@@ -57,7 +57,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
@@ -49,13 +49,24 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aladdin Connect sensor devices."""
|
||||
coordinators = entry.runtime_data
|
||||
coordinator = entry.runtime_data
|
||||
known_devices: set[str] = set()
|
||||
|
||||
async_add_entities(
|
||||
AladdinConnectSensor(coordinator, description)
|
||||
for coordinator in coordinators.values()
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
@callback
|
||||
def _async_add_new_devices() -> None:
|
||||
"""Detect and add entities for new doors."""
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AladdinConnectSensor(coordinator, door_id, description)
|
||||
for door_id in new_devices
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
_async_add_new_devices()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
|
||||
|
||||
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||
@@ -66,14 +77,15 @@ class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AladdinConnectCoordinator,
|
||||
door_id: str,
|
||||
entity_description: AladdinConnectSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Aladdin Connect sensor."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(coordinator, door_id)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
|
||||
self._attr_unique_id = f"{door_id}-{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
return self.entity_description.value_fn(self.door)
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"close_door_failed": {
|
||||
"message": "Failed to close the garage door"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"open_door_failed": {
|
||||
"message": "Failed to open the garage door"
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Arm away"
|
||||
"name": "Arm alarm away"
|
||||
},
|
||||
"alarm_arm_custom_bypass": {
|
||||
"description": "Arms an alarm while allowing to bypass a custom area.",
|
||||
@@ -183,7 +183,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Arm with custom bypass"
|
||||
"name": "Arm alarm with custom bypass"
|
||||
},
|
||||
"alarm_arm_home": {
|
||||
"description": "Arms an alarm in the home mode.",
|
||||
@@ -193,7 +193,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Arm home"
|
||||
"name": "Arm alarm home"
|
||||
},
|
||||
"alarm_arm_night": {
|
||||
"description": "Arms an alarm in the night mode.",
|
||||
@@ -203,7 +203,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Arm night"
|
||||
"name": "Arm alarm night"
|
||||
},
|
||||
"alarm_arm_vacation": {
|
||||
"description": "Arms an alarm in the vacation mode.",
|
||||
@@ -213,7 +213,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Arm vacation"
|
||||
"name": "Arm alarm vacation"
|
||||
},
|
||||
"alarm_disarm": {
|
||||
"description": "Disarms an alarm.",
|
||||
@@ -223,7 +223,7 @@
|
||||
"name": "Code"
|
||||
}
|
||||
},
|
||||
"name": "Disarm"
|
||||
"name": "Disarm alarm"
|
||||
},
|
||||
"alarm_trigger": {
|
||||
"description": "Triggers an alarm manually.",
|
||||
@@ -233,7 +233,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Trigger"
|
||||
"name": "Trigger alarm"
|
||||
}
|
||||
},
|
||||
"title": "Alarm control panel",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.0.1"]
|
||||
"requirements": ["aioamazondevices==13.3.1"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["androidtvremote2==0.2.3"],
|
||||
"requirements": ["androidtvremote2==0.3.1"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyanglianwater"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyanglianwater==3.1.1"]
|
||||
"requirements": ["pyanglianwater==3.1.2"]
|
||||
}
|
||||
|
||||
@@ -45,9 +45,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
try:
|
||||
await client.models.list(timeout=10.0)
|
||||
except anthropic.AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"message": err.message
|
||||
if isinstance(err, anthropic.APIError)
|
||||
else str(err)
|
||||
},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -60,7 +61,7 @@ class AnthropicTaskEntity(
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
raise HomeAssistantError(
|
||||
"Last content in chat log is not an AssistantContent"
|
||||
translation_domain=DOMAIN, translation_key="response_not_found"
|
||||
)
|
||||
|
||||
text = chat_log.content[-1].content or ""
|
||||
@@ -78,7 +79,9 @@ class AnthropicTaskEntity(
|
||||
err,
|
||||
text,
|
||||
)
|
||||
raise HomeAssistantError("Error with Claude structured response") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="json_parse_error"
|
||||
) from err
|
||||
|
||||
return ai_task.GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
|
||||
@@ -401,7 +401,11 @@ def _convert_content(
|
||||
messages[-1]["content"] = messages[-1]["content"][0]["text"]
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
|
||||
raise HomeAssistantError("Unexpected content type in chat log")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_chat_log_content",
|
||||
translation_placeholders={"type": type(content).__name__},
|
||||
)
|
||||
|
||||
return messages, container_id
|
||||
|
||||
@@ -443,7 +447,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if stream is None or not hasattr(stream, "__aiter__"):
|
||||
raise HomeAssistantError("Expected a stream of messages")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
|
||||
)
|
||||
|
||||
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
|
||||
current_tool_args: str
|
||||
@@ -605,7 +611,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
content_details.container = response.delta.container
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_refusal"
|
||||
)
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
@@ -664,7 +672,9 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise HomeAssistantError("First message must be a system message")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="system_message_not_found"
|
||||
)
|
||||
|
||||
# System prompt with caching enabled
|
||||
system_prompt: list[TextBlockParam] = [
|
||||
@@ -754,7 +764,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
last_message = messages[-1]
|
||||
if last_message["role"] != "user":
|
||||
raise HomeAssistantError(
|
||||
"Last message must be a user message to add attachments"
|
||||
translation_domain=DOMAIN, translation_key="user_message_not_found"
|
||||
)
|
||||
if isinstance(last_message["content"], str):
|
||||
last_message["content"] = [
|
||||
@@ -859,11 +869,19 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
except anthropic.AuthenticationError as err:
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(
|
||||
"Authentication error with Anthropic API, reauthentication required"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"message": err.message
|
||||
if isinstance(err, anthropic.APIError)
|
||||
else str(err)
|
||||
},
|
||||
) from err
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
@@ -883,15 +901,23 @@ async def async_prepare_files_for_prompt(
|
||||
|
||||
for file_path, mime_type in files:
|
||||
if not file_path.exists():
|
||||
raise HomeAssistantError(f"`{file_path}` does not exist")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="wrong_file_path",
|
||||
translation_placeholders={"file_path": file_path.as_posix()},
|
||||
)
|
||||
|
||||
if mime_type is None:
|
||||
mime_type = guess_file_type(file_path)[0]
|
||||
|
||||
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
|
||||
raise HomeAssistantError(
|
||||
"Only images and PDF are supported by the Anthropic API,"
|
||||
f"`{file_path}` is not an image file or PDF"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="wrong_file_type",
|
||||
translation_placeholders={
|
||||
"file_path": file_path.as_posix(),
|
||||
"mime_type": mime_type or "unknown",
|
||||
},
|
||||
)
|
||||
if mime_type == "image/jpg":
|
||||
mime_type = "image/jpeg"
|
||||
|
||||
@@ -59,10 +59,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
No data updates.
|
||||
docs-examples:
|
||||
status: todo
|
||||
comment: |
|
||||
To give examples of how people use the integration
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
@@ -88,7 +85,7 @@ rules:
|
||||
comment: |
|
||||
No entities disabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: done
|
||||
|
||||
@@ -161,7 +161,9 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
is None
|
||||
or (subentry := entry.subentries.get(self._current_subentry_id)) is None
|
||||
):
|
||||
raise HomeAssistantError("Subentry not found")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="subentry_not_found"
|
||||
)
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
@@ -190,4 +192,6 @@ async def async_create_fix_flow(
|
||||
"""Create flow."""
|
||||
if issue_id == "model_deprecated":
|
||||
return ModelDeprecatedRepairFlow()
|
||||
raise HomeAssistantError("Unknown issue ID")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="unknown_issue_id"
|
||||
)
|
||||
|
||||
@@ -149,6 +149,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_authentication_error": {
|
||||
"message": "Authentication error with Anthropic API: {message}. Reauthentication required."
|
||||
},
|
||||
"api_error": {
|
||||
"message": "Anthropic API error: {message}."
|
||||
},
|
||||
"api_refusal": {
|
||||
"message": "Potential policy violation detected."
|
||||
},
|
||||
"json_parse_error": {
|
||||
"message": "Error with Claude structured response."
|
||||
},
|
||||
"response_not_found": {
|
||||
"message": "Last content in chat log is not an AssistantContent."
|
||||
},
|
||||
"subentry_not_found": {
|
||||
"message": "Subentry not found."
|
||||
},
|
||||
"system_message_not_found": {
|
||||
"message": "First message must be a system message."
|
||||
},
|
||||
"unexpected_chat_log_content": {
|
||||
"message": "Unexpected content type in chat log: {type}."
|
||||
},
|
||||
"unexpected_stream_object": {
|
||||
"message": "Expected a stream of messages."
|
||||
},
|
||||
"unknown_issue_id": {
|
||||
"message": "Unknown issue ID."
|
||||
},
|
||||
"user_message_not_found": {
|
||||
"message": "Last message must be a user message to add attachments."
|
||||
},
|
||||
"wrong_file_path": {
|
||||
"message": "`{file_path}` does not exist."
|
||||
},
|
||||
"wrong_file_type": {
|
||||
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"model_deprecated": {
|
||||
"fix_flow": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from contextlib import AsyncExitStack
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
@@ -54,36 +54,31 @@ async def _run_client(
|
||||
client = runtime_data.client
|
||||
coordinators = runtime_data.coordinators
|
||||
|
||||
def _listen(_: Any) -> None:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_data_updated()
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with timeout(interval):
|
||||
await client.start()
|
||||
async with AsyncExitStack() as stack:
|
||||
async with timeout(interval):
|
||||
await client.start()
|
||||
stack.push_async_callback(client.stop)
|
||||
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
await coordinator.state.start()
|
||||
|
||||
with client.listen(_listen):
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_connected()
|
||||
await client.process()
|
||||
finally:
|
||||
await client.stop()
|
||||
await stack.enter_async_context(
|
||||
coordinator.async_monitor_client()
|
||||
)
|
||||
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_disconnected()
|
||||
await client.process()
|
||||
finally:
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
|
||||
except ConnectionFailed:
|
||||
await asyncio.sleep(interval)
|
||||
pass
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception, aborting arcam client")
|
||||
return
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -51,7 +53,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
self.client = client
|
||||
self.state = State(client, zone)
|
||||
self.last_update_success = False
|
||||
self.update_in_progress = False
|
||||
|
||||
name = config_entry.title
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
@@ -74,24 +76,34 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data for manual refresh."""
|
||||
try:
|
||||
self.update_in_progress = True
|
||||
await self.state.update()
|
||||
except ConnectionFailed as err:
|
||||
raise UpdateFailed(
|
||||
f"Connection failed during update for zone {self.state.zn}"
|
||||
) from err
|
||||
finally:
|
||||
self.update_in_progress = False
|
||||
|
||||
@callback
|
||||
def async_notify_data_updated(self) -> None:
|
||||
"""Notify that new data has been received from the device."""
|
||||
self.async_set_updated_data(None)
|
||||
def _async_notify_packet(self, packet: ResponsePacket | AmxDuetResponse) -> None:
|
||||
"""Packet callback to detect changes to state."""
|
||||
if (
|
||||
not isinstance(packet, ResponsePacket)
|
||||
or packet.zn != self.state.zn
|
||||
or self.update_in_progress
|
||||
):
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_notify_connected(self) -> None:
|
||||
"""Handle client connected."""
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@callback
|
||||
def async_notify_disconnected(self) -> None:
|
||||
"""Handle client disconnected."""
|
||||
self.last_update_success = False
|
||||
self.async_update_listeners()
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_monitor_client(self) -> AsyncGenerator[None]:
|
||||
"""Monitor a client and state for changes while connected."""
|
||||
async with self.state:
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
try:
|
||||
with self.client.listen(self._async_notify_packet):
|
||||
yield
|
||||
finally:
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@@ -26,3 +26,8 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
|
||||
if description is not None:
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.client.connected
|
||||
|
||||
@@ -137,5 +137,4 @@ async def async_pipeline_from_audio_stream(
|
||||
audio_settings=audio_settings or AudioSettings(),
|
||||
),
|
||||
)
|
||||
await pipeline_input.validate()
|
||||
await pipeline_input.execute()
|
||||
await pipeline_input.execute(validate=True)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"""Assist pipeline errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .pipeline import PipelineStage
|
||||
|
||||
|
||||
class PipelineError(HomeAssistantError):
|
||||
"""Base class for pipeline errors."""
|
||||
@@ -55,3 +62,25 @@ class IntentRecognitionError(PipelineError):
|
||||
|
||||
class TextToSpeechError(PipelineError):
|
||||
"""Error in text-to-speech portion of pipeline."""
|
||||
|
||||
|
||||
class PipelineRunValidationError(PipelineError):
|
||||
"""Error when a pipeline run is not valid."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
"""Set error message."""
|
||||
super().__init__("validation-error", message)
|
||||
|
||||
|
||||
class InvalidPipelineStagesError(PipelineRunValidationError):
|
||||
"""Error when given an invalid combination of start/end stages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
start_stage: PipelineStage,
|
||||
end_stage: PipelineStage,
|
||||
) -> None:
|
||||
"""Set error message."""
|
||||
super().__init__(
|
||||
f"Invalid stage combination: start={start_stage}, end={end_stage}"
|
||||
)
|
||||
|
||||
@@ -73,8 +73,10 @@ from .const import (
|
||||
from .error import (
|
||||
DuplicateWakeUpDetectedError,
|
||||
IntentRecognitionError,
|
||||
InvalidPipelineStagesError,
|
||||
PipelineError,
|
||||
PipelineNotFound,
|
||||
PipelineRunValidationError,
|
||||
SpeechToTextError,
|
||||
TextToSpeechError,
|
||||
WakeWordDetectionAborted,
|
||||
@@ -492,24 +494,6 @@ PIPELINE_STAGE_ORDER = [
|
||||
]
|
||||
|
||||
|
||||
class PipelineRunValidationError(Exception):
|
||||
"""Error when a pipeline run is not valid."""
|
||||
|
||||
|
||||
class InvalidPipelineStagesError(PipelineRunValidationError):
|
||||
"""Error when given an invalid combination of start/end stages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
start_stage: PipelineStage,
|
||||
end_stage: PipelineStage,
|
||||
) -> None:
|
||||
"""Set error message."""
|
||||
super().__init__(
|
||||
f"Invalid stage combination: start={start_stage}, end={end_stage}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WakeWordSettings:
|
||||
"""Settings for wake word detection."""
|
||||
@@ -662,7 +646,8 @@ class PipelineRun:
|
||||
"""Emit run start event."""
|
||||
self._device_id = device_id
|
||||
self._satellite_id = satellite_id
|
||||
self._start_debug_recording_thread()
|
||||
if self.start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT):
|
||||
self._start_debug_recording_thread()
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"pipeline": self.pipeline.id,
|
||||
@@ -1504,9 +1489,7 @@ class PipelineRun:
|
||||
|
||||
def _start_debug_recording_thread(self) -> None:
|
||||
"""Start thread to record wake/stt audio if debug_recording_dir is set."""
|
||||
if self.debug_recording_thread is not None:
|
||||
# Already started
|
||||
return
|
||||
assert self.debug_recording_thread is None
|
||||
|
||||
# Directory to save audio for each pipeline run.
|
||||
# Configured in YAML for assist_pipeline.
|
||||
@@ -1681,26 +1664,39 @@ class PipelineInput:
|
||||
satellite_id: str | None = None
|
||||
"""Identifier of the satellite that is processing the input/output of the pipeline."""
|
||||
|
||||
async def execute(self) -> None:
|
||||
async def execute(self, validate: bool = False) -> None:
|
||||
"""Run pipeline."""
|
||||
validation_error: PipelineError | None = None
|
||||
if validate:
|
||||
try:
|
||||
await self.validate()
|
||||
except PipelineError as err:
|
||||
validation_error = err
|
||||
|
||||
self.run.start(
|
||||
conversation_id=self.session.conversation_id,
|
||||
device_id=self.device_id,
|
||||
satellite_id=self.satellite_id,
|
||||
)
|
||||
current_stage: PipelineStage | None = self.run.start_stage
|
||||
stt_audio_buffer: list[EnhancedAudioChunk] = []
|
||||
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
|
||||
|
||||
if self.stt_stream is not None:
|
||||
if self.run.audio_settings.needs_processor:
|
||||
# VAD/noise suppression/auto gain/volume
|
||||
stt_processed_stream = self.run.process_enhance_audio(self.stt_stream)
|
||||
else:
|
||||
# Volume multiplier only
|
||||
stt_processed_stream = self.run.process_volume_only(self.stt_stream)
|
||||
|
||||
try:
|
||||
if validation_error is not None:
|
||||
raise validation_error
|
||||
|
||||
stt_audio_buffer: list[EnhancedAudioChunk] = []
|
||||
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
|
||||
|
||||
if self.stt_stream is not None:
|
||||
if self.run.audio_settings.needs_processor:
|
||||
# VAD/noise suppression/auto gain/volume
|
||||
stt_processed_stream = self.run.process_enhance_audio(
|
||||
self.stt_stream
|
||||
)
|
||||
else:
|
||||
# Volume multiplier only
|
||||
stt_processed_stream = self.run.process_volume_only(self.stt_stream)
|
||||
|
||||
if current_stage == PipelineStage.WAKE_WORD:
|
||||
# wake-word-detection
|
||||
assert stt_processed_stream is not None
|
||||
|
||||
@@ -626,7 +626,7 @@ def websocket_delete_all_refresh_tokens(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle delete all refresh tokens request."""
|
||||
current_refresh_token: RefreshToken
|
||||
current_refresh_token: RefreshToken | None = None
|
||||
remove_failed = False
|
||||
token_type = msg.get("token_type")
|
||||
delete_current_token = msg.get("delete_current_token")
|
||||
@@ -654,7 +654,7 @@ def websocket_delete_all_refresh_tokens(
|
||||
else:
|
||||
connection.send_result(msg["id"], {})
|
||||
|
||||
async def _delete_current_token_soon() -> None:
|
||||
async def _delete_current_token_soon(current_refresh_token: RefreshToken) -> None:
|
||||
"""Delete the current token after a delay.
|
||||
|
||||
We do not want to delete the current token immediately as it will
|
||||
@@ -675,13 +675,15 @@ def websocket_delete_all_refresh_tokens(
|
||||
# the token right away.
|
||||
hass.auth.async_remove_refresh_token(current_refresh_token)
|
||||
|
||||
if delete_current_token and (
|
||||
not limit_token_types or current_refresh_token.token_type == token_type
|
||||
if (
|
||||
delete_current_token
|
||||
and current_refresh_token
|
||||
and (not limit_token_types or current_refresh_token.token_type == token_type)
|
||||
):
|
||||
# Deleting the token will close the connection so we need
|
||||
# to do it with a delay in a tracked task to ensure it still
|
||||
# happens if Home Assistant is shutting down.
|
||||
hass.async_create_task(_delete_current_token_soon())
|
||||
hass.async_create_task(_delete_current_token_soon(current_refresh_token))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
|
||||
@@ -115,6 +115,7 @@ def async_setup(
|
||||
) -> None:
|
||||
"""Component to allow users to login."""
|
||||
hass.http.register_view(WellKnownOAuthInfoView)
|
||||
hass.http.register_view(WellKnownProtectedResourceView)
|
||||
hass.http.register_view(AuthProvidersView)
|
||||
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow, store_result))
|
||||
hass.http.register_view(LoginFlowResourceView(hass.auth.login_flow, store_result))
|
||||
@@ -141,6 +142,13 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
# Home Assistant already accepts URL-based client_ids via
|
||||
# IndieAuth without prior registration, which is compatible with
|
||||
# draft-ietf-oauth-client-id-metadata-document. This flag
|
||||
# advertises that support to encourage clients to use it. The
|
||||
# metadata document is not actually fetched as IndieAuth doesn't
|
||||
# require it.
|
||||
"client_id_metadata_document_supported": True,
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
@@ -154,6 +162,32 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
return self.json(metadata)
|
||||
|
||||
|
||||
class WellKnownProtectedResourceView(HomeAssistantView):
|
||||
"""View to host the OAuth2 Protected Resource Metadata per RFC9728."""
|
||||
|
||||
requires_auth = False
|
||||
url = "/.well-known/oauth-protected-resource"
|
||||
name = "well-known/oauth-protected-resource"
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Return the protected resource metadata."""
|
||||
hass = request.app[KEY_HASS]
|
||||
try:
|
||||
url_prefix = get_url(hass, require_current_request=True)
|
||||
except NoURLAvailableError:
|
||||
return self.json_message("No URL available", HTTPStatus.NOT_FOUND)
|
||||
|
||||
return self.json(
|
||||
{
|
||||
"resource": url_prefix,
|
||||
"authorization_servers": [url_prefix],
|
||||
"resource_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuthProvidersView(HomeAssistantView):
|
||||
"""View to get available auth providers."""
|
||||
|
||||
|
||||
@@ -118,27 +118,11 @@ SERVICE_TRIGGER = "trigger"
|
||||
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
|
||||
|
||||
_EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"air_quality",
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"humidifier",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"person",
|
||||
"siren",
|
||||
"switch",
|
||||
"vacuum",
|
||||
}
|
||||
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"button",
|
||||
"battery",
|
||||
"calendar",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
@@ -148,22 +132,69 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"input_boolean",
|
||||
"illuminance",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"moisture",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"person",
|
||||
"power",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"window",
|
||||
}
|
||||
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"air_quality",
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"button",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"door",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"moisture",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"person",
|
||||
"power",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"window",
|
||||
}
|
||||
|
||||
|
||||
@@ -78,11 +78,11 @@
|
||||
"services": {
|
||||
"reload": {
|
||||
"description": "Reloads the automation configuration.",
|
||||
"name": "[%key:common::action::reload%]"
|
||||
"name": "Reload automations"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Toggles (enable / disable) an automation.",
|
||||
"name": "[%key:common::action::toggle%]"
|
||||
"name": "Toggle automation"
|
||||
},
|
||||
"trigger": {
|
||||
"description": "Triggers the actions of an automation.",
|
||||
@@ -92,7 +92,7 @@
|
||||
"name": "Skip conditions"
|
||||
}
|
||||
},
|
||||
"name": "Trigger"
|
||||
"name": "Trigger automation"
|
||||
},
|
||||
"turn_off": {
|
||||
"description": "Disables an automation.",
|
||||
@@ -102,11 +102,11 @@
|
||||
"name": "Stop actions"
|
||||
}
|
||||
},
|
||||
"name": "[%key:common::action::turn_off%]"
|
||||
"name": "Turn off automation"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Enables an automation.",
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
"name": "Turn on automation"
|
||||
}
|
||||
},
|
||||
"title": "Automation"
|
||||
|
||||
@@ -147,7 +147,7 @@ class S3BackupAgent(BackupAgent):
|
||||
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
await self._upload_simple(tar_filename, open_stream)
|
||||
else:
|
||||
await self._upload_multipart(tar_filename, open_stream)
|
||||
await self._upload_multipart(tar_filename, open_stream, on_progress)
|
||||
|
||||
# Upload the metadata file
|
||||
metadata_content = json.dumps(backup.as_dict())
|
||||
@@ -188,11 +188,13 @@ class S3BackupAgent(BackupAgent):
|
||||
self,
|
||||
tar_filename: str,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
):
|
||||
on_progress: OnProgressCallback,
|
||||
) -> None:
|
||||
"""Upload a large file using multipart upload.
|
||||
|
||||
:param tar_filename: The target filename for the backup.
|
||||
:param open_stream: A function returning an async iterator that yields bytes.
|
||||
:param on_progress: A callback to report the number of uploaded bytes.
|
||||
"""
|
||||
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
|
||||
multipart_upload = await self._client.create_multipart_upload(
|
||||
@@ -205,6 +207,7 @@ class S3BackupAgent(BackupAgent):
|
||||
part_number = 1
|
||||
buffer = bytearray() # bytes buffer to store the data
|
||||
offset = 0 # start index of unread data inside buffer
|
||||
bytes_uploaded = 0
|
||||
|
||||
stream = await open_stream()
|
||||
async for chunk in stream:
|
||||
@@ -233,6 +236,8 @@ class S3BackupAgent(BackupAgent):
|
||||
Body=part_data.tobytes(),
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
bytes_uploaded += len(part_data)
|
||||
on_progress(bytes_uploaded=bytes_uploaded)
|
||||
part_number += 1
|
||||
finally:
|
||||
view.release()
|
||||
@@ -261,6 +266,8 @@ class S3BackupAgent(BackupAgent):
|
||||
Body=remaining_data.tobytes(),
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
|
||||
bytes_uploaded += len(remaining_data)
|
||||
on_progress(bytes_uploaded=bytes_uploaded)
|
||||
|
||||
await cast(Any, self._client).complete_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from ..const import LOGGER
|
||||
from ..errors import AuthenticationRequired, CannotConnect
|
||||
@@ -26,7 +26,7 @@ async def get_axis_api(
|
||||
config: Mapping[str, Any],
|
||||
) -> axis.AxisDevice:
|
||||
"""Create a Axis device API."""
|
||||
session = get_async_client(hass, verify_ssl=False)
|
||||
session = async_get_clientsession(hass, verify_ssl=False)
|
||||
|
||||
api = axis.AxisDevice(
|
||||
Configuration(
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==66"],
|
||||
"requirements": ["axis==67"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -34,4 +34,4 @@ EXCLUDE_DATABASE_FROM_BACKUP = [
|
||||
"home-assistant_v2.db-wal",
|
||||
]
|
||||
|
||||
SECURETAR_CREATE_VERSION = 2
|
||||
SECURETAR_CREATE_VERSION = 3
|
||||
|
||||
@@ -246,6 +246,8 @@ def decrypt_backup(
|
||||
except (DecryptError, SecureTarError, tarfile.TarError) as err:
|
||||
LOGGER.warning("Error decrypting backup: %s", err)
|
||||
error = err
|
||||
except Abort:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
|
||||
error = err
|
||||
@@ -332,8 +334,10 @@ def encrypt_backup(
|
||||
except (EncryptError, SecureTarError, tarfile.TarError) as err:
|
||||
LOGGER.warning("Error encrypting backup: %s", err)
|
||||
error = err
|
||||
except Abort:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
|
||||
LOGGER.exception("Unexpected error when encrypting backup: %s", err)
|
||||
error = err
|
||||
else:
|
||||
# Pad the output stream to the requested minimum size
|
||||
|
||||
17
homeassistant/components/battery/__init__.py
Normal file
17
homeassistant/components/battery/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Integration for battery triggers and conditions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "battery"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
__all__ = []
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
46
homeassistant/components/battery/condition.py
Normal file
46
homeassistant/components/battery/condition.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Provides conditions for batteries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
make_entity_numerical_condition,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
|
||||
BATTERY_DOMAIN_SPECS = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY)
|
||||
}
|
||||
BATTERY_CHARGING_DOMAIN_SPECS = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING
|
||||
)
|
||||
}
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS = {
|
||||
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
|
||||
}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_ON),
|
||||
"is_not_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_OFF),
|
||||
"is_charging": make_entity_state_condition(BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON),
|
||||
"is_not_charging": make_entity_state_condition(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
|
||||
),
|
||||
"is_level": make_entity_numerical_condition(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for batteries."""
|
||||
return CONDITIONS
|
||||
64
homeassistant/components/battery/conditions.yaml
Normal file
64
homeassistant/components/battery/conditions.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
.condition_common: &condition_common
|
||||
target: &target_battery_binary_sensor
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery
|
||||
fields:
|
||||
behavior: &condition_behavior
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
.battery_threshold_entity: &battery_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "%"
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
- domain: number
|
||||
device_class: battery
|
||||
|
||||
.battery_threshold_number: &battery_threshold_number
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
|
||||
is_low: *condition_common
|
||||
|
||||
is_not_low: *condition_common
|
||||
|
||||
is_charging:
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery_charging
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
|
||||
is_not_charging:
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery_charging
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
|
||||
is_level:
|
||||
target:
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *battery_threshold_entity
|
||||
mode: is
|
||||
number: *battery_threshold_number
|
||||
39
homeassistant/components/battery/icons.json
Normal file
39
homeassistant/components/battery/icons.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_charging": {
|
||||
"condition": "mdi:battery-charging"
|
||||
},
|
||||
"is_level": {
|
||||
"condition": "mdi:battery-unknown"
|
||||
},
|
||||
"is_low": {
|
||||
"condition": "mdi:battery-alert"
|
||||
},
|
||||
"is_not_charging": {
|
||||
"condition": "mdi:battery"
|
||||
},
|
||||
"is_not_low": {
|
||||
"condition": "mdi:battery"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"level_changed": {
|
||||
"trigger": "mdi:battery-unknown"
|
||||
},
|
||||
"level_crossed_threshold": {
|
||||
"trigger": "mdi:battery-alert"
|
||||
},
|
||||
"low": {
|
||||
"trigger": "mdi:battery-alert"
|
||||
},
|
||||
"not_low": {
|
||||
"trigger": "mdi:battery"
|
||||
},
|
||||
"started_charging": {
|
||||
"trigger": "mdi:battery-charging"
|
||||
},
|
||||
"stopped_charging": {
|
||||
"trigger": "mdi:battery"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/battery/manifest.json
Normal file
8
homeassistant/components/battery/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "battery",
|
||||
"name": "Battery",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/battery",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
151
homeassistant/components/battery/strings.json
Normal file
151
homeassistant/components/battery/strings.json
Normal file
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted batteries.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"condition_threshold_description": "What to test for and threshold values.",
|
||||
"condition_threshold_name": "Threshold configuration",
|
||||
"trigger_behavior_description": "The behavior of the targeted batteries to trigger on.",
|
||||
"trigger_behavior_name": "Behavior",
|
||||
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
|
||||
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
|
||||
"trigger_threshold_name": "Threshold configuration"
|
||||
},
|
||||
"conditions": {
|
||||
"is_charging": {
|
||||
"description": "Tests if one or more batteries are charging.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery is charging"
|
||||
},
|
||||
"is_level": {
|
||||
"description": "Tests the battery level of one or more batteries.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::battery::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::battery::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery level"
|
||||
},
|
||||
"is_low": {
|
||||
"description": "Tests if one or more batteries are low.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery is low"
|
||||
},
|
||||
"is_not_charging": {
|
||||
"description": "Tests if one or more batteries are not charging.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery is not charging"
|
||||
},
|
||||
"is_not_low": {
|
||||
"description": "Tests if one or more batteries are not low.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery is not low"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Battery",
|
||||
"triggers": {
|
||||
"level_changed": {
|
||||
"description": "Triggers after the battery level of one or more batteries changes.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"description": "[%key:component::battery::common::trigger_threshold_changed_description%]",
|
||||
"name": "[%key:component::battery::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery level changed"
|
||||
},
|
||||
"level_crossed_threshold": {
|
||||
"description": "Triggers after the battery level of one or more batteries crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::trigger_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::battery::common::trigger_threshold_crossed_description%]",
|
||||
"name": "[%key:component::battery::common::trigger_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery level crossed threshold"
|
||||
},
|
||||
"low": {
|
||||
"description": "Triggers after one or more batteries become low.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery low"
|
||||
},
|
||||
"not_low": {
|
||||
"description": "Triggers after one or more batteries are no longer low.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery not low"
|
||||
},
|
||||
"started_charging": {
|
||||
"description": "Triggers after one or more batteries start charging.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery started charging"
|
||||
},
|
||||
"stopped_charging": {
|
||||
"description": "Triggers after one or more batteries stop charging.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::battery::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::battery::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery stopped charging"
|
||||
}
|
||||
}
|
||||
}
|
||||
54
homeassistant/components/battery/trigger.py
Normal file
54
homeassistant/components/battery/trigger.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Provides triggers for batteries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
BATTERY_LOW_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY),
|
||||
}
|
||||
|
||||
BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING
|
||||
),
|
||||
}
|
||||
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||||
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
|
||||
}
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
|
||||
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
|
||||
"started_charging": make_entity_target_state_trigger(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
|
||||
),
|
||||
"stopped_charging": make_entity_target_state_trigger(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
|
||||
),
|
||||
"level_changed": make_entity_numerical_state_changed_trigger(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
|
||||
),
|
||||
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for batteries."""
|
||||
return TRIGGERS
|
||||
83
homeassistant/components/battery/triggers.yaml
Normal file
83
homeassistant/components/battery/triggers.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
.battery_threshold_entity: &battery_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "%"
|
||||
- domain: number
|
||||
device_class: battery
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
|
||||
.battery_threshold_number: &battery_threshold_number
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
|
||||
.trigger_target_battery: &trigger_target_battery
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery
|
||||
|
||||
.trigger_target_charging: &trigger_target_charging
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery_charging
|
||||
|
||||
.trigger_target_percentage: &trigger_target_percentage
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
|
||||
low:
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
target: *trigger_target_battery
|
||||
|
||||
not_low:
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
target: *trigger_target_battery
|
||||
|
||||
started_charging:
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
target: *trigger_target_charging
|
||||
|
||||
stopped_charging:
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
target: *trigger_target_charging
|
||||
|
||||
level_changed:
|
||||
target: *trigger_target_percentage
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *battery_threshold_entity
|
||||
mode: changed
|
||||
number: *battery_threshold_number
|
||||
|
||||
level_crossed_threshold:
|
||||
target: *trigger_target_percentage
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *battery_threshold_entity
|
||||
mode: crossed
|
||||
number: *battery_threshold_number
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluesound",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyblu==2.0.5"],
|
||||
"requirements": ["pyblu==2.0.6"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_musc._tcp.local."
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.10.2"
|
||||
"habluetooth==5.11.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_PASSKEY, DOMAIN
|
||||
from .const import CONF_PASSKEY, DOMAIN, LOGGER
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -52,7 +52,7 @@ class BSBLanData:
|
||||
client: BSBLAN
|
||||
device: Device
|
||||
info: Info
|
||||
static: StaticState
|
||||
static: StaticState | None
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -82,11 +82,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
|
||||
# the connection by fetching firmware version
|
||||
await bsblan.initialize()
|
||||
|
||||
# Fetch device metadata in parallel for faster startup
|
||||
device, info, static = await asyncio.gather(
|
||||
# Fetch required device metadata in parallel for faster startup
|
||||
device, info = await asyncio.gather(
|
||||
bsblan.device(),
|
||||
bsblan.info(),
|
||||
bsblan.static_values(),
|
||||
)
|
||||
except BSBLANConnectionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
@@ -111,6 +110,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
|
||||
translation_key="setup_general_error",
|
||||
) from err
|
||||
|
||||
try:
|
||||
static = await bsblan.static_values()
|
||||
except (BSBLANError, TimeoutError) as err:
|
||||
LOGGER.debug(
|
||||
"Static values not available for %s: %s",
|
||||
entry.data[CONF_HOST],
|
||||
err,
|
||||
)
|
||||
static = None
|
||||
|
||||
# Create coordinators with the already-initialized client
|
||||
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
|
||||
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
|
||||
|
||||
@@ -90,10 +90,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
|
||||
|
||||
# Set temperature range if available, otherwise use Home Assistant defaults
|
||||
if data.static.min_temp is not None and data.static.min_temp.value is not None:
|
||||
self._attr_min_temp = data.static.min_temp.value
|
||||
if data.static.max_temp is not None and data.static.max_temp.value is not None:
|
||||
self._attr_max_temp = data.static.max_temp.value
|
||||
if (static := data.static) is not None:
|
||||
if (min_temp := static.min_temp) is not None and min_temp.value is not None:
|
||||
self._attr_min_temp = min_temp.value
|
||||
if (max_temp := static.max_temp) is not None and max_temp.value is not None:
|
||||
self._attr_max_temp = max_temp.value
|
||||
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||
|
||||
@property
|
||||
|
||||
@@ -183,90 +183,122 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
existing_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is None:
|
||||
# Preserve existing values as defaults
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=existing_entry.data.get(
|
||||
CONF_PASSKEY, vol.UNDEFINED
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=existing_entry.data.get(
|
||||
CONF_USERNAME, vol.UNDEFINED
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
data_schema=self._build_credentials_schema(existing_entry.data),
|
||||
)
|
||||
|
||||
# Combine existing data with the user's new input for validation.
|
||||
# This correctly handles adding, changing, and clearing credentials.
|
||||
config_data = existing_entry.data.copy()
|
||||
config_data.update(user_input)
|
||||
# Merge existing data with user input for validation
|
||||
validate_data = {**existing_entry.data, **user_input}
|
||||
errors = await self._async_validate_credentials(validate_data)
|
||||
|
||||
self.host = config_data[CONF_HOST]
|
||||
self.port = config_data[CONF_PORT]
|
||||
self.passkey = config_data.get(CONF_PASSKEY)
|
||||
self.username = config_data.get(CONF_USERNAME)
|
||||
self.password = config_data.get(CONF_PASSWORD)
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self._build_credentials_schema(user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry, data_updates=user_input, reason="reauth_successful"
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration flow."""
|
||||
existing_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self._build_connection_schema(existing_entry.data),
|
||||
)
|
||||
|
||||
# Merge existing data with user input for validation
|
||||
validate_data = {**existing_entry.data, **user_input}
|
||||
errors = await self._async_validate_credentials(validate_data)
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self._build_connection_schema(user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# Prevent reconfiguring to a different physical device
|
||||
# it gets the unique ID from the device info when it validates credentials
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry,
|
||||
data_updates=user_input,
|
||||
reason="reconfigure_successful",
|
||||
)
|
||||
|
||||
async def _async_validate_credentials(self, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate connection credentials and return errors dict."""
|
||||
self.host = data[CONF_HOST]
|
||||
self.port = data.get(CONF_PORT, DEFAULT_PORT)
|
||||
self.passkey = data.get(CONF_PASSKEY)
|
||||
self.username = data.get(CONF_USERNAME)
|
||||
self.password = data.get(CONF_PASSWORD)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
|
||||
except BSBLANAuthError:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "invalid_auth"},
|
||||
)
|
||||
errors["base"] = "invalid_auth"
|
||||
except BSBLANError:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "cannot_connect"},
|
||||
)
|
||||
errors["base"] = "cannot_connect"
|
||||
return errors
|
||||
|
||||
# Update only the fields that were provided by the user
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry, data_updates=user_input, reason="reauth_successful"
|
||||
@callback
|
||||
def _build_credentials_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
|
||||
"""Build schema for credentials-only forms (reauth)."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
def _build_connection_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
|
||||
"""Build schema for full connection forms (user and reconfigure)."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HOST,
|
||||
default=defaults.get(CONF_HOST, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PORT,
|
||||
default=defaults.get(CONF_PORT, DEFAULT_PORT),
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -274,32 +306,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the setup form to the user."""
|
||||
# Preserve user input if provided, otherwise use defaults
|
||||
defaults = user_input or {}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
data_schema=self._build_connection_schema(user_input or {}),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"sensor": data.fast_coordinator.data.sensor.model_dump(),
|
||||
"dhw": data.fast_coordinator.data.dhw.model_dump(),
|
||||
},
|
||||
"static": data.static.model_dump(),
|
||||
"static": data.static.model_dump() if data.static is not None else None,
|
||||
}
|
||||
|
||||
# Add DHW config and schedule from slow coordinator if available
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.1.2"],
|
||||
"requirements": ["python-bsblan==5.1.3"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -58,7 +58,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"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%]",
|
||||
"unique_id_mismatch": "The device you are trying to reconfigure is not the same as the one previously configured."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -39,6 +41,24 @@
|
||||
"description": "The BSB-LAN integration needs to re-authenticate with {name}",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::bsblan::config::step::user::data_description::host%]",
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
|
||||
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
|
||||
"port": "[%key:component::bsblan::config::step::user::data_description::port%]",
|
||||
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "Update connection settings for your BSB-LAN device.",
|
||||
"title": "Reconfigure BSB-LAN"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
},
|
||||
"services": {
|
||||
"press": {
|
||||
"description": "Presses a button entity.",
|
||||
"name": "Press"
|
||||
"description": "Presses a button.",
|
||||
"name": "Press button"
|
||||
}
|
||||
},
|
||||
"title": "Button",
|
||||
|
||||
@@ -578,13 +578,13 @@ class CalendarEntity(Entity):
|
||||
return STATE_OFF
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
This sets up listeners to handle state transitions for start or end of
|
||||
the current or upcoming event.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
if self._alarm_unsubs is None:
|
||||
self._alarm_unsubs = []
|
||||
_LOGGER.debug(
|
||||
|
||||
16
homeassistant/components/calendar/condition.py
Normal file
16
homeassistant/components/calendar/condition.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Provides conditions for calendars."""
|
||||
|
||||
from homeassistant.const import STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_event_active": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the calendar conditions."""
|
||||
return CONDITIONS
|
||||
14
homeassistant/components/calendar/conditions.yaml
Normal file
14
homeassistant/components/calendar/conditions.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
is_event_active:
|
||||
target:
|
||||
entity:
|
||||
- domain: calendar
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_event_active": {
|
||||
"condition": "mdi:calendar-check"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:calendar",
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted calendars.",
|
||||
"condition_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_event_active": {
|
||||
"description": "Tests if one or more calendars have an active event.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::calendar::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::calendar::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Calendar event is active"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::calendar::title%]",
|
||||
@@ -46,6 +62,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_offset_type": {
|
||||
"options": {
|
||||
"after": "After",
|
||||
@@ -90,7 +112,7 @@
|
||||
"name": "Summary"
|
||||
}
|
||||
},
|
||||
"name": "Create event"
|
||||
"name": "Create calendar event"
|
||||
},
|
||||
"get_events": {
|
||||
"description": "Retrieves events on a calendar within a time range.",
|
||||
@@ -108,7 +130,7 @@
|
||||
"name": "Start time"
|
||||
}
|
||||
},
|
||||
"name": "Get events"
|
||||
"name": "Get calendar events"
|
||||
}
|
||||
},
|
||||
"title": "Calendar",
|
||||
|
||||
@@ -432,6 +432,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
)
|
||||
|
||||
# Entity Properties
|
||||
entity_description: CameraEntityDescription
|
||||
_attr_brand: str | None = None
|
||||
_attr_frame_interval: float = MIN_STREAM_INTERVAL
|
||||
_attr_is_on: bool = True
|
||||
@@ -759,12 +760,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return CameraCapabilities(frontend_stream_types)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
Schedules async_refresh_providers if support of streams have changed.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
if self.__supports_stream != (
|
||||
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
||||
):
|
||||
|
||||
@@ -51,11 +51,11 @@
|
||||
"services": {
|
||||
"disable_motion_detection": {
|
||||
"description": "Disables the motion detection of a camera.",
|
||||
"name": "Disable motion detection"
|
||||
"name": "Disable camera motion detection"
|
||||
},
|
||||
"enable_motion_detection": {
|
||||
"description": "Enables the motion detection of a camera.",
|
||||
"name": "Enable motion detection"
|
||||
"name": "Enable camera motion detection"
|
||||
},
|
||||
"play_stream": {
|
||||
"description": "Plays a camera stream on a supported media player.",
|
||||
@@ -69,7 +69,7 @@
|
||||
"name": "Media player"
|
||||
}
|
||||
},
|
||||
"name": "Play stream"
|
||||
"name": "Play camera stream"
|
||||
},
|
||||
"record": {
|
||||
"description": "Creates a recording of a live camera feed.",
|
||||
@@ -87,7 +87,7 @@
|
||||
"name": "Lookback"
|
||||
}
|
||||
},
|
||||
"name": "Record"
|
||||
"name": "Record camera feed"
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "Takes a snapshot from a camera.",
|
||||
@@ -97,15 +97,15 @@
|
||||
"name": "Filename"
|
||||
}
|
||||
},
|
||||
"name": "Take snapshot"
|
||||
"name": "Take camera snapshot"
|
||||
},
|
||||
"turn_off": {
|
||||
"description": "Turns off a camera.",
|
||||
"name": "[%key:common::action::turn_off%]"
|
||||
"name": "Turn off camera"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Turns on a camera.",
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
"name": "Turn on camera"
|
||||
}
|
||||
},
|
||||
"title": "Camera"
|
||||
|
||||
44
homeassistant/components/casper_glow/__init__.py
Normal file
44
homeassistant/components/casper_glow/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""The Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycasperglow import CasperGlow
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
|
||||
"""Set up Casper Glow from a config entry."""
|
||||
address: str = entry.data[CONF_ADDRESS]
|
||||
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Casper Glow device with address {address}"
|
||||
)
|
||||
|
||||
glow = CasperGlow(ble_device)
|
||||
coordinator = CasperGlowCoordinator(hass, glow, entry.title)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(coordinator.async_start())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
51
homeassistant/components/casper_glow/binary_sensor.py
Normal file
51
homeassistant/components/casper_glow/binary_sensor.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Casper Glow integration binary sensor platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CasperGlowConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the binary sensor platform for Casper Glow."""
|
||||
async_add_entities([CasperGlowPausedBinarySensor(entry.runtime_data)])
|
||||
|
||||
|
||||
class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity):
|
||||
"""Binary sensor indicating whether the Casper Glow dimming is paused."""
|
||||
|
||||
_attr_translation_key = "paused"
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize the paused binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_paused"
|
||||
if coordinator.device.state.is_paused is not None:
|
||||
self._attr_is_on = coordinator.device.state.is_paused
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state update callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
if state.is_paused is not None:
|
||||
self._attr_is_on = state.is_paused
|
||||
self.async_write_ha_state()
|
||||
73
homeassistant/components/casper_glow/button.py
Normal file
73
homeassistant/components/casper_glow/button.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Casper Glow integration button platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pycasperglow import CasperGlow
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CasperGlowButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describe a Casper Glow button entity."""
|
||||
|
||||
press_fn: Callable[[CasperGlow], Awaitable[None]]
|
||||
|
||||
|
||||
BUTTON_DESCRIPTIONS: tuple[CasperGlowButtonEntityDescription, ...] = (
|
||||
CasperGlowButtonEntityDescription(
|
||||
key="pause",
|
||||
translation_key="pause",
|
||||
press_fn=lambda device: device.pause(),
|
||||
),
|
||||
CasperGlowButtonEntityDescription(
|
||||
key="resume",
|
||||
translation_key="resume",
|
||||
press_fn=lambda device: device.resume(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CasperGlowConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the button platform for Casper Glow."""
|
||||
async_add_entities(
|
||||
CasperGlowButton(entry.runtime_data, description)
|
||||
for description in BUTTON_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class CasperGlowButton(CasperGlowEntity, ButtonEntity):
|
||||
"""A Casper Glow button entity."""
|
||||
|
||||
entity_description: CasperGlowButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CasperGlowCoordinator,
|
||||
description: CasperGlowButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Casper Glow button."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = (
|
||||
f"{format_mac(coordinator.device.address)}_{description.key}"
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
await self._async_command(self.entity_description.press_fn(self._device))
|
||||
151
homeassistant/components/casper_glow/config_flow.py
Normal file
151
homeassistant/components/casper_glow/config_flow.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Config flow for Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bluetooth_data_tools import human_readable_name
|
||||
from pycasperglow import CasperGlow, CasperGlowError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import DOMAIN, LOCAL_NAMES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CasperGlowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Casper Glow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
await self.async_set_unique_id(format_mac(discovery_info.address))
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = discovery_info
|
||||
self.context["title_placeholders"] = {
|
||||
"name": human_readable_name(
|
||||
None, discovery_info.name, discovery_info.address
|
||||
)
|
||||
}
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
|
||||
async def async_step_bluetooth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm a discovered Casper Glow device."""
|
||||
assert self._discovery_info is not None
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self.context["title_placeholders"]["name"],
|
||||
data={CONF_ADDRESS: self._discovery_info.address},
|
||||
)
|
||||
glow = CasperGlow(self._discovery_info.device)
|
||||
try:
|
||||
await glow.handshake()
|
||||
except CasperGlowError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error during Casper Glow config flow "
|
||||
"(step=bluetooth_confirm, address=%s)",
|
||||
self._discovery_info.address,
|
||||
)
|
||||
return self.async_abort(reason="unknown")
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
description_placeholders=self.context["title_placeholders"],
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step to pick discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
address = user_input[CONF_ADDRESS]
|
||||
discovery_info = self._discovered_devices[address]
|
||||
await self.async_set_unique_id(
|
||||
format_mac(discovery_info.address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
glow = CasperGlow(discovery_info.device)
|
||||
try:
|
||||
await glow.handshake()
|
||||
except CasperGlowError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error during Casper Glow config flow "
|
||||
"(step=user, address=%s)",
|
||||
discovery_info.address,
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=human_readable_name(
|
||||
None, discovery_info.name, discovery_info.address
|
||||
),
|
||||
data={
|
||||
CONF_ADDRESS: discovery_info.address,
|
||||
},
|
||||
)
|
||||
|
||||
if discovery := self._discovery_info:
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
else:
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in async_discovered_service_info(self.hass):
|
||||
if (
|
||||
format_mac(discovery.address) in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not (
|
||||
discovery.name
|
||||
and any(
|
||||
discovery.name.startswith(local_name)
|
||||
for local_name in LOCAL_NAMES
|
||||
)
|
||||
)
|
||||
):
|
||||
continue
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
service_info.address: human_readable_name(
|
||||
None, service_info.name, service_info.address
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
18
homeassistant/components/casper_glow/const.py
Normal file
18
homeassistant/components/casper_glow/const.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Constants for the Casper Glow integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pycasperglow import BRIGHTNESS_LEVELS, DEVICE_NAME_PREFIX, DIMMING_TIME_MINUTES
|
||||
|
||||
DOMAIN = "casper_glow"
|
||||
|
||||
LOCAL_NAMES = {DEVICE_NAME_PREFIX}
|
||||
|
||||
SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
|
||||
|
||||
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
|
||||
|
||||
DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES)
|
||||
|
||||
# Interval between periodic state polls to catch externally-triggered changes.
|
||||
STATE_POLL_INTERVAL = timedelta(seconds=30)
|
||||
112
homeassistant/components/casper_glow/coordinator.py
Normal file
112
homeassistant/components/casper_glow/coordinator.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Coordinator for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bleak import BleakError
|
||||
from bluetooth_data_tools import monotonic_time_coarse
|
||||
from pycasperglow import CasperGlow
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from homeassistant.components.bluetooth.active_update_coordinator import (
|
||||
ActiveBluetoothDataUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type CasperGlowConfigEntry = ConfigEntry[CasperGlowCoordinator]
|
||||
|
||||
|
||||
class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
|
||||
"""Coordinator for Casper Glow BLE devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
device: CasperGlow,
|
||||
title: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
address=device.address,
|
||||
mode=BluetoothScanningMode.PASSIVE,
|
||||
needs_poll_method=self._needs_poll,
|
||||
poll_method=self._async_update,
|
||||
connectable=True,
|
||||
)
|
||||
self.device = device
|
||||
self.last_dimming_time_minutes: int | None = (
|
||||
device.state.configured_dimming_time_minutes
|
||||
)
|
||||
self.title = title
|
||||
|
||||
# The device API couples brightness and dimming time into a
|
||||
# single command (set_brightness_and_dimming_time), so both
|
||||
# values must be tracked here for cross-entity use.
|
||||
self.last_brightness_pct: int = (
|
||||
device.state.brightness_level
|
||||
if device.state.brightness_level is not None
|
||||
else SORTED_BRIGHTNESS_LEVELS[0]
|
||||
)
|
||||
|
||||
@callback
|
||||
def _needs_poll(
|
||||
self,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
seconds_since_last_poll: float | None,
|
||||
) -> bool:
|
||||
"""Return True if a poll is needed."""
|
||||
return (
|
||||
seconds_since_last_poll is None
|
||||
or seconds_since_last_poll >= STATE_POLL_INTERVAL.total_seconds()
|
||||
)
|
||||
|
||||
async def _async_update(self, service_info: BluetoothServiceInfoBleak) -> None:
|
||||
"""Poll device state."""
|
||||
await self.device.query_state()
|
||||
|
||||
async def _async_poll(self) -> None:
|
||||
"""Poll the device and log availability changes."""
|
||||
assert self._last_service_info
|
||||
|
||||
try:
|
||||
await self._async_poll_data(self._last_service_info)
|
||||
except BleakError as exc:
|
||||
if self.last_poll_successful:
|
||||
_LOGGER.info("%s is unavailable: %s", self.title, exc)
|
||||
self.last_poll_successful = False
|
||||
return
|
||||
except Exception:
|
||||
if self.last_poll_successful:
|
||||
_LOGGER.exception("%s: unexpected error while polling", self.title)
|
||||
self.last_poll_successful = False
|
||||
return
|
||||
finally:
|
||||
self._last_poll = monotonic_time_coarse()
|
||||
|
||||
if not self.last_poll_successful:
|
||||
_LOGGER.info("%s is back online", self.title)
|
||||
self.last_poll_successful = True
|
||||
|
||||
self._async_handle_bluetooth_poll()
|
||||
|
||||
@callback
|
||||
def _async_handle_bluetooth_event(
|
||||
self,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
change: BluetoothChange,
|
||||
) -> None:
|
||||
"""Update BLE device reference on each advertisement."""
|
||||
self.device.set_ble_device(service_info.device)
|
||||
super()._async_handle_bluetooth_event(service_info, change)
|
||||
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Diagnostics support for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry
|
||||
|
||||
SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"})
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: CasperGlowConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
service_info = bluetooth.async_last_service_info(
|
||||
hass, coordinator.device.address, connectable=True
|
||||
)
|
||||
|
||||
return {
|
||||
"service_info": async_redact_data(
|
||||
service_info.as_dict() if service_info else None,
|
||||
SERVICE_INFO_TO_REDACT,
|
||||
),
|
||||
}
|
||||
47
homeassistant/components/casper_glow/entity.py
Normal file
47
homeassistant/components/casper_glow/entity.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Base entity for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
|
||||
from pycasperglow import CasperGlowError
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
PassiveBluetoothCoordinatorEntity,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CasperGlowCoordinator
|
||||
|
||||
|
||||
class CasperGlowEntity(PassiveBluetoothCoordinatorEntity[CasperGlowCoordinator]):
|
||||
"""Base class for Casper Glow entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize a Casper Glow entity."""
|
||||
super().__init__(coordinator)
|
||||
self._device = coordinator.device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
manufacturer="Casper",
|
||||
model="Glow",
|
||||
model_id="G01",
|
||||
connections={
|
||||
(dr.CONNECTION_BLUETOOTH, format_mac(coordinator.device.address))
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_command(self, coro: Awaitable[None]) -> None:
|
||||
"""Execute a device command."""
|
||||
try:
|
||||
await coro
|
||||
except CasperGlowError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
22
homeassistant/components/casper_glow/icons.json
Normal file
22
homeassistant/components/casper_glow/icons.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"paused": {
|
||||
"default": "mdi:timer-pause"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"pause": {
|
||||
"default": "mdi:pause"
|
||||
},
|
||||
"resume": {
|
||||
"default": "mdi:play"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"dimming_time": {
|
||||
"default": "mdi:timer-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
homeassistant/components/casper_glow/light.py
Normal file
106
homeassistant/components/casper_glow/light.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Casper Glow integration light platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_DIMMING_TIME_MINUTES, SORTED_BRIGHTNESS_LEVELS
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
def _ha_brightness_to_device_pct(brightness: int) -> int:
|
||||
"""Convert HA brightness (1-255) to device percentage by snapping to nearest."""
|
||||
return percentage_to_ordered_list_item(
|
||||
SORTED_BRIGHTNESS_LEVELS, round(brightness * 100 / 255)
|
||||
)
|
||||
|
||||
|
||||
def _device_pct_to_ha_brightness(pct: int) -> int:
|
||||
"""Convert device brightness percentage (60-100) to HA brightness (1-255)."""
|
||||
percent = ordered_list_item_to_percentage(SORTED_BRIGHTNESS_LEVELS, pct)
|
||||
return round(percent * 255 / 100)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CasperGlowConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the light platform for Casper Glow."""
|
||||
async_add_entities([CasperGlowLight(entry.runtime_data)])
|
||||
|
||||
|
||||
class CasperGlowLight(CasperGlowEntity, LightEntity):
|
||||
"""Representation of a Casper Glow light."""
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize a Casper Glow light."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = format_mac(coordinator.device.address)
|
||||
self._update_from_state(coordinator.device.state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state update callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_state(self, state: GlowState) -> None:
|
||||
"""Update entity attributes from device state."""
|
||||
if state.is_on is not None:
|
||||
self._attr_is_on = state.is_on
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
if state.brightness_level is not None:
|
||||
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
|
||||
self.coordinator.last_brightness_pct = state.brightness_level
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
self._update_from_state(state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
brightness_pct: int | None = None
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness_pct = _ha_brightness_to_device_pct(kwargs[ATTR_BRIGHTNESS])
|
||||
|
||||
await self._async_command(self._device.turn_on())
|
||||
self._attr_is_on = True
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
if brightness_pct is not None:
|
||||
await self._async_command(
|
||||
self._device.set_brightness_and_dimming_time(
|
||||
brightness_pct,
|
||||
self.coordinator.last_dimming_time_minutes
|
||||
if self.coordinator.last_dimming_time_minutes is not None
|
||||
else DEFAULT_DIMMING_TIME_MINUTES,
|
||||
)
|
||||
)
|
||||
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
|
||||
self.coordinator.last_brightness_pct = brightness_pct
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self._async_command(self._device.turn_off())
|
||||
self._attr_is_on = False
|
||||
19
homeassistant/components/casper_glow/manifest.json
Normal file
19
homeassistant/components/casper_glow/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"domain": "casper_glow",
|
||||
"name": "Casper Glow",
|
||||
"bluetooth": [
|
||||
{
|
||||
"connectable": true,
|
||||
"local_name": "Jar*"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@mikeodr"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/casper_glow",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycasperglow"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pycasperglow==1.1.0"]
|
||||
}
|
||||
72
homeassistant/components/casper_glow/quality_scale.yaml
Normal file
72
homeassistant/components/casper_glow/quality_scale.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No custom services.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No custom actions/services.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Bluetooth device with no authentication credentials.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No network discovery.
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: No applicable device classes for binary_sensor, button, light, or select entities.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: No web session is used by this integration.
|
||||
strict-typing: done
|
||||
92
homeassistant/components/casper_glow/select.py
Normal file
92
homeassistant/components/casper_glow/select.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Casper Glow integration select platform for dimming time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.const import EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DIMMING_TIME_OPTIONS
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CasperGlowConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the select platform for Casper Glow."""
|
||||
async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)])
|
||||
|
||||
|
||||
class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity):
|
||||
"""Select entity for Casper Glow dimming time."""
|
||||
|
||||
_attr_translation_key = "dimming_time"
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_options = list(DIMMING_TIME_OPTIONS)
|
||||
_attr_unit_of_measurement = UnitOfTime.MINUTES
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize the dimming time select entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the currently selected dimming time from the coordinator."""
|
||||
if self.coordinator.last_dimming_time_minutes is None:
|
||||
return None
|
||||
return str(self.coordinator.last_dimming_time_minutes)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last known dimming time and register state update callback."""
|
||||
await super().async_added_to_hass()
|
||||
if self.coordinator.last_dimming_time_minutes is None and (
|
||||
last_state := await self.async_get_last_state()
|
||||
):
|
||||
if last_state.state in DIMMING_TIME_OPTIONS:
|
||||
self.coordinator.last_dimming_time_minutes = int(last_state.state)
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
if state.brightness_level is not None:
|
||||
self.coordinator.last_brightness_pct = state.brightness_level
|
||||
if (
|
||||
state.configured_dimming_time_minutes is not None
|
||||
and self.coordinator.last_dimming_time_minutes is None
|
||||
):
|
||||
self.coordinator.last_dimming_time_minutes = (
|
||||
state.configured_dimming_time_minutes
|
||||
)
|
||||
# Dimming time is not part of the device state
|
||||
# that is provided via BLE update. Therefore
|
||||
# we need to trigger a state update for the select entity
|
||||
# to update the current state.
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the dimming time."""
|
||||
await self._async_command(
|
||||
self._device.set_brightness_and_dimming_time(
|
||||
self.coordinator.last_brightness_pct, int(option)
|
||||
)
|
||||
)
|
||||
self.coordinator.last_dimming_time_minutes = int(option)
|
||||
# Dimming time is not part of the device state
|
||||
# that is provided via BLE update. Therefore
|
||||
# we need to trigger a state update for the select entity
|
||||
# to update the current state.
|
||||
self.async_write_ha_state()
|
||||
54
homeassistant/components/casper_glow/strings.json
Normal file
54
homeassistant/components/casper_glow/strings.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "Do you want to set up {name}?"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "Bluetooth address"
|
||||
},
|
||||
"data_description": {
|
||||
"address": "The Bluetooth address of the Casper Glow light"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"paused": {
|
||||
"name": "Dimming paused"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"pause": {
|
||||
"name": "Pause dimming"
|
||||
},
|
||||
"resume": {
|
||||
"name": "Resume dimming"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"dimming_time": {
|
||||
"name": "Dimming time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "An error occurred while communicating with the Casper Glow: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,68 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"chess960_daily_draw": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess960_daily_lost": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess960_daily_rating": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"chess960_daily_won": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_blitz_draw": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_blitz_lost": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_blitz_rating": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"chess_blitz_won": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_bullet_draw": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_bullet_lost": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_bullet_rating": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"chess_bullet_won": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_daily_draw": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_daily_lost": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_daily_rating": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"chess_daily_won": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_rapid_draw": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_rapid_lost": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"chess_rapid_rating": {
|
||||
"default": "mdi:chart-line"
|
||||
},
|
||||
"chess_rapid_won": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"followers": {
|
||||
"default": "mdi:account-multiple"
|
||||
},
|
||||
"total_daily_draw": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"total_daily_lost": {
|
||||
"default": "mdi:chess-pawn"
|
||||
},
|
||||
"total_daily_won": {
|
||||
"default": "mdi:chess-pawn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from chess_com_api import PlayerStats
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
@@ -24,7 +27,14 @@ class ChessEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[ChessData], float]
|
||||
|
||||
|
||||
SENSORS: tuple[ChessEntityDescription, ...] = (
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ChessModeEntityDescription(SensorEntityDescription):
|
||||
"""Sensor description for a Chess.com game mode."""
|
||||
|
||||
value_fn: Callable[[dict[str, Any]], float]
|
||||
|
||||
|
||||
PLAYER_SENSORS: tuple[ChessEntityDescription, ...] = (
|
||||
ChessEntityDescription(
|
||||
key="followers",
|
||||
translation_key="followers",
|
||||
@@ -33,35 +43,46 @@ SENSORS: tuple[ChessEntityDescription, ...] = (
|
||||
value_fn=lambda state: state.player.followers,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="chess_daily_rating",
|
||||
translation_key="chess_daily_rating",
|
||||
)
|
||||
|
||||
GAME_MODE_SENSORS: tuple[ChessModeEntityDescription, ...] = (
|
||||
ChessModeEntityDescription(
|
||||
key="rating",
|
||||
translation_key="rating",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda state: state.stats.chess_daily["last"]["rating"],
|
||||
value_fn=lambda mode: mode["last"]["rating"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_won",
|
||||
translation_key="total_daily_won",
|
||||
ChessModeEntityDescription(
|
||||
key="won",
|
||||
translation_key="won",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["win"],
|
||||
value_fn=lambda mode: mode["record"]["win"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_lost",
|
||||
translation_key="total_daily_lost",
|
||||
ChessModeEntityDescription(
|
||||
key="lost",
|
||||
translation_key="lost",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["loss"],
|
||||
value_fn=lambda mode: mode["record"]["loss"],
|
||||
),
|
||||
ChessEntityDescription(
|
||||
key="total_daily_draw",
|
||||
translation_key="total_daily_draw",
|
||||
ChessModeEntityDescription(
|
||||
key="draw",
|
||||
translation_key="draw",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda state: state.stats.chess_daily["record"]["draw"],
|
||||
value_fn=lambda mode: mode["record"]["draw"],
|
||||
),
|
||||
)
|
||||
|
||||
GAME_MODES: dict[str, Callable[[PlayerStats], dict[str, Any] | None]] = {
|
||||
"chess_daily": lambda stats: stats.chess_daily,
|
||||
"chess_rapid": lambda stats: stats.chess_rapid,
|
||||
"chess_bullet": lambda stats: stats.chess_bullet,
|
||||
"chess_blitz": lambda stats: stats.chess_blitz,
|
||||
"chess960_daily": lambda stats: stats.chess960_daily,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -71,13 +92,22 @@ async def async_setup_entry(
|
||||
"""Initialize the entries."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
ChessPlayerSensor(coordinator, description) for description in SENSORS
|
||||
)
|
||||
entities: list[SensorEntity] = [
|
||||
ChessPlayerSensor(coordinator, description) for description in PLAYER_SENSORS
|
||||
]
|
||||
|
||||
for game_mode, stats_fn in GAME_MODES.items():
|
||||
if stats_fn(coordinator.data.stats) is not None:
|
||||
entities.extend(
|
||||
ChessGameModeSensor(coordinator, description, game_mode, stats_fn)
|
||||
for description in GAME_MODE_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ChessPlayerSensor(ChessEntity, SensorEntity):
|
||||
"""Chess.com sensor."""
|
||||
"""Chess.com player sensor."""
|
||||
|
||||
entity_description: ChessEntityDescription
|
||||
|
||||
@@ -95,3 +125,33 @@ class ChessPlayerSensor(ChessEntity, SensorEntity):
|
||||
def native_value(self) -> float:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
|
||||
class ChessGameModeSensor(ChessEntity, SensorEntity):
|
||||
"""Chess.com game mode sensor."""
|
||||
|
||||
entity_description: ChessModeEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ChessCoordinator,
|
||||
description: ChessModeEntityDescription,
|
||||
game_mode: str,
|
||||
stats_fn: Callable[[PlayerStats], dict[str, Any] | None],
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._stats_fn = stats_fn
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}.{game_mode}.{description.key}"
|
||||
)
|
||||
self._attr_translation_key = f"{game_mode}_{description.translation_key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the state of the sensor."""
|
||||
mode_data = self._stats_fn(self.coordinator.data.stats)
|
||||
if TYPE_CHECKING:
|
||||
assert mode_data is not None
|
||||
return self.entity_description.value_fn(mode_data)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user