forked from home-assistant/core
Compare commits
660 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3d43745d | ||
|
|
6b56985e01 | ||
|
|
ed41421a3d | ||
|
|
f019131352 | ||
|
|
4ec313cb3b | ||
|
|
68e33fdbf5 | ||
|
|
312ad7057d | ||
|
|
0d49b19624 | ||
|
|
6d9c37d636 | ||
|
|
ed881f399f | ||
|
|
3a466195b9 | ||
|
|
3453d31f01 | ||
|
|
afa0d37ff0 | ||
|
|
57c96a5489 | ||
|
|
6fb8378b45 | ||
|
|
27a9f5a05c | ||
|
|
16ab799798 | ||
|
|
03488af3fb | ||
|
|
dbb3802b4e | ||
|
|
ead38f6005 | ||
|
|
c2525bede2 | ||
|
|
b79057348d | ||
|
|
6b18b92bdd | ||
|
|
ada0f7cf65 | ||
|
|
87a0118082 | ||
|
|
688bdc6532 | ||
|
|
bba9ef7d7d | ||
|
|
635252ec8e | ||
|
|
a10ca95c01 | ||
|
|
4244ea78d0 | ||
|
|
5aa2bd81cf | ||
|
|
61d5b3028d | ||
|
|
b9f4a7220e | ||
|
|
2ea53e0787 | ||
|
|
7c302bfd7e | ||
|
|
ff80fc347b | ||
|
|
4b541f4058 | ||
|
|
855274e354 | ||
|
|
43eaa960e8 | ||
|
|
81a0ce621e | ||
|
|
18d36e011a | ||
|
|
6d44245456 | ||
|
|
4b90ed6b22 | ||
|
|
cc8b811572 | ||
|
|
faeee4f7ad | ||
|
|
9aa6037219 | ||
|
|
d0742cb332 | ||
|
|
e096532cf1 | ||
|
|
25e5864a22 | ||
|
|
338077f557 | ||
|
|
f925d9ca6b | ||
|
|
b1c9f8d55d | ||
|
|
32eb4e518b | ||
|
|
9928b977fd | ||
|
|
dc9da79a1c | ||
|
|
2ba86310f0 | ||
|
|
457708cbda | ||
|
|
82d6fe5bd5 | ||
|
|
dae4543e54 | ||
|
|
33c5e09ac2 | ||
|
|
f09cea1499 | ||
|
|
14c39f7c24 | ||
|
|
b83a405b14 | ||
|
|
699a38de52 | ||
|
|
fe14be53e3 | ||
|
|
b32e6fe0d5 | ||
|
|
d05450487c | ||
|
|
f9aa364b6d | ||
|
|
5eab4f1dcc | ||
|
|
4c59a6522a | ||
|
|
40bb4266c9 | ||
|
|
bf8b201bb3 | ||
|
|
cd0da4ed0e | ||
|
|
22acc03fb8 | ||
|
|
10831a0889 | ||
|
|
5de4f546f9 | ||
|
|
2efa297df1 | ||
|
|
98229899dc | ||
|
|
0a792620f8 | ||
|
|
54f6cfd569 | ||
|
|
70fff26383 | ||
|
|
dc11e41cf0 | ||
|
|
a6e091f60f | ||
|
|
1428919f98 | ||
|
|
6f9943787a | ||
|
|
796b195c73 | ||
|
|
47f8d248f7 | ||
|
|
b9a0f40827 | ||
|
|
6b204941cf | ||
|
|
4d62e77049 | ||
|
|
b80bed64f5 | ||
|
|
18b7f74ad7 | ||
|
|
b2081c579b | ||
|
|
bef85ecd2e | ||
|
|
e0f50a9e54 | ||
|
|
a8797a08c6 | ||
|
|
edb7ec78f9 | ||
|
|
4a1da0b041 | ||
|
|
0b84eefa2e | ||
|
|
50888ae339 | ||
|
|
a9f796a97c | ||
|
|
10ff169c76 | ||
|
|
0b22880f22 | ||
|
|
5a4e6bbb07 | ||
|
|
0776456b59 | ||
|
|
01fc322488 | ||
|
|
2a2af80309 | ||
|
|
2765440aa5 | ||
|
|
43e174899d | ||
|
|
07b6aaec63 | ||
|
|
ef53a2d118 | ||
|
|
1099018a5e | ||
|
|
4bdb21a871 | ||
|
|
6880be5aeb | ||
|
|
f0e187e306 | ||
|
|
7c5ac88aae | ||
|
|
54c57fe5db | ||
|
|
b444dfe8a6 | ||
|
|
fb226e3e3b | ||
|
|
3a3d488de3 | ||
|
|
30841ef4da | ||
|
|
501b3f9927 | ||
|
|
5efc61feaf | ||
|
|
28abc30b4d | ||
|
|
0471e15c28 | ||
|
|
ec28ee3c42 | ||
|
|
1281da024c | ||
|
|
c789f11ef8 | ||
|
|
dbd5396dc7 | ||
|
|
71900ca719 | ||
|
|
c15445159d | ||
|
|
dd885a456e | ||
|
|
b5c9eca654 | ||
|
|
dcf925a67f | ||
|
|
d42d8543c8 | ||
|
|
fa0185a481 | ||
|
|
28d2f9bd87 | ||
|
|
27ea59f6c3 | ||
|
|
ae776e2d28 | ||
|
|
fed5d0f5be | ||
|
|
4f134f339c | ||
|
|
264d18bc83 | ||
|
|
4c1d978aa4 | ||
|
|
196fe4b927 | ||
|
|
a9de9aa58d | ||
|
|
e874093818 | ||
|
|
b10149c2a0 | ||
|
|
c71a6ee562 | ||
|
|
57ee514d70 | ||
|
|
4692605974 | ||
|
|
2b82830eb1 | ||
|
|
ff1dba3529 | ||
|
|
257a91d929 | ||
|
|
23a579421d | ||
|
|
1568de62df | ||
|
|
a7e98f12f4 | ||
|
|
8cec559103 | ||
|
|
258fe1f09b | ||
|
|
e5487722a8 | ||
|
|
7f0dd442fd | ||
|
|
ef6c39f911 | ||
|
|
7317b1bb8b | ||
|
|
c0ae7b1a49 | ||
|
|
686a856a17 | ||
|
|
51e6371991 | ||
|
|
96c233d4b9 | ||
|
|
17fbeb6245 | ||
|
|
c59e049050 | ||
|
|
6c64b315db | ||
|
|
2f6ef08959 | ||
|
|
9c8e10936b | ||
|
|
f2c7e3fed4 | ||
|
|
6adbf3ba84 | ||
|
|
da10598fa1 | ||
|
|
6c8ed86f3e | ||
|
|
f1005d37a7 | ||
|
|
d270d52cb5 | ||
|
|
6e26713184 | ||
|
|
e9c19462d6 | ||
|
|
57ccd8283d | ||
|
|
4ffacec4be | ||
|
|
44bf5ba001 | ||
|
|
77e4f69af0 | ||
|
|
8861909ea4 | ||
|
|
4b124e4c25 | ||
|
|
c45beeef6d | ||
|
|
a158397b6d | ||
|
|
a1fb6ae38f | ||
|
|
8c67ebc143 | ||
|
|
40d8bd43a1 | ||
|
|
c7ea1d07be | ||
|
|
e60de53404 | ||
|
|
3a1dc16c0d | ||
|
|
a6568fba7a | ||
|
|
0ab9e33110 | ||
|
|
8483850729 | ||
|
|
90608da5c2 | ||
|
|
6b8835b196 | ||
|
|
f55ab9d4ea | ||
|
|
23cc4d1453 | ||
|
|
f613cd38fc | ||
|
|
45238295df | ||
|
|
65bd308491 | ||
|
|
30345489e6 | ||
|
|
2bf36bb1db | ||
|
|
cc90cba78a | ||
|
|
a08bab7b18 | ||
|
|
f9c02889b2 | ||
|
|
9d4de2a722 | ||
|
|
92c5249746 | ||
|
|
b031ded671 | ||
|
|
5a295ad42b | ||
|
|
266477a4f5 | ||
|
|
07e3843918 | ||
|
|
c26c8affc7 | ||
|
|
629dd24ff3 | ||
|
|
c545d2e22e | ||
|
|
06383a4383 | ||
|
|
3a7083900c | ||
|
|
e2d3beca22 | ||
|
|
70dbbbd974 | ||
|
|
d60b7d46b7 | ||
|
|
d83f20f743 | ||
|
|
1c147b5c5f | ||
|
|
9c62149b00 | ||
|
|
027920ff52 | ||
|
|
a30921e67d | ||
|
|
7f9cc10447 | ||
|
|
b88cf64850 | ||
|
|
004179775c | ||
|
|
f95bd9c78f | ||
|
|
b97f0c0261 | ||
|
|
e886576a64 | ||
|
|
bb11b0f067 | ||
|
|
1135446de4 | ||
|
|
a262d0f9e4 | ||
|
|
7a7c2ad416 | ||
|
|
d425aabae3 | ||
|
|
8d44b721c6 | ||
|
|
baa1801e13 | ||
|
|
965e47eb6a | ||
|
|
16c0301227 | ||
|
|
37096a2b65 | ||
|
|
bead08840e | ||
|
|
b7b55f941c | ||
|
|
4231775e04 | ||
|
|
14d90b5484 | ||
|
|
afa48c54e9 | ||
|
|
fb680bc1e4 | ||
|
|
4f98818258 | ||
|
|
a5a896b519 | ||
|
|
0eb0faff03 | ||
|
|
4a23d4c7d3 | ||
|
|
377c61203d | ||
|
|
74a93fe764 | ||
|
|
ddbfdf14e9 | ||
|
|
e8ec74b944 | ||
|
|
f60f9bae00 | ||
|
|
eada1a184c | ||
|
|
34cfdb4e35 | ||
|
|
85e6f92c5a | ||
|
|
9efb90d23c | ||
|
|
66aa7d0e68 | ||
|
|
9f790325bb | ||
|
|
0fa7186296 | ||
|
|
90df932fe1 | ||
|
|
7436c0fe42 | ||
|
|
6766d25e62 | ||
|
|
9d9e11372b | ||
|
|
8ea0a8d40b | ||
|
|
56c7e78cf2 | ||
|
|
2fc0dfecb1 | ||
|
|
8c6b9b57cd | ||
|
|
f8438e96d1 | ||
|
|
2926989ec3 | ||
|
|
6603b3eccd | ||
|
|
e2bf3ac095 | ||
|
|
ced96775fe | ||
|
|
f65e57bf7b | ||
|
|
7d9e257713 | ||
|
|
031ee71adf | ||
|
|
d03dfd985b | ||
|
|
0e868deedd | ||
|
|
4984030871 | ||
|
|
7de509dc76 | ||
|
|
88cda043ac | ||
|
|
404fbe388c | ||
|
|
a0bc96c20d | ||
|
|
6f4657fe02 | ||
|
|
c0cd2d48ec | ||
|
|
d1eb5da5f4 | ||
|
|
c7492b0feb | ||
|
|
c20322232a | ||
|
|
61ca9bb8e4 | ||
|
|
1f8156e26c | ||
|
|
3e7b908a61 | ||
|
|
eb4a44535c | ||
|
|
e98476e026 | ||
|
|
aa45ff83bd | ||
|
|
029d006beb | ||
|
|
ab8cf4f1e4 | ||
|
|
557720b094 | ||
|
|
92e19f6001 | ||
|
|
f4f42176bd | ||
|
|
e94eb686a6 | ||
|
|
2da5a02285 | ||
|
|
e3b1008511 | ||
|
|
cb874fefbb | ||
|
|
0454a5fa3f | ||
|
|
d8f6331318 | ||
|
|
d7459c73e0 | ||
|
|
fa9fe4067a | ||
|
|
59581786d3 | ||
|
|
55aaa894c3 | ||
|
|
3cf8610cb3 | ||
|
|
802497b05c | ||
|
|
df2f476c67 | ||
|
|
da338f2c1a | ||
|
|
fdea9cb426 | ||
|
|
18bc772cbb | ||
|
|
a5072f0fe4 | ||
|
|
76c26da4cb | ||
|
|
3528d865b7 | ||
|
|
e6c224fa40 | ||
|
|
048f219a7f | ||
|
|
945b84a7df | ||
|
|
fe2d24c240 | ||
|
|
faab0aa9df | ||
|
|
a521b885bf | ||
|
|
a744dc270b | ||
|
|
866c2ca994 | ||
|
|
bc8414a6f8 | ||
|
|
527f9cdfb2 | ||
|
|
4c04fe652c | ||
|
|
5e65e27bda | ||
|
|
7d3a962f73 | ||
|
|
ce998cdc87 | ||
|
|
dbbbfaa869 | ||
|
|
863edfd660 | ||
|
|
4d4967d0dd | ||
|
|
4b4f51fb6f | ||
|
|
30064655c2 | ||
|
|
fd5b92b2fb | ||
|
|
6e55c2a345 | ||
|
|
2134331e2b | ||
|
|
ffe83d9ab1 | ||
|
|
f2f649680f | ||
|
|
ece7b498ed | ||
|
|
65c2a25736 | ||
|
|
05586de51f | ||
|
|
a58b3aad59 | ||
|
|
e567e3d4e7 | ||
|
|
385f0298bd | ||
|
|
7edd241059 | ||
|
|
5bf6951311 | ||
|
|
24d0aa3f55 | ||
|
|
5ff7563070 | ||
|
|
def4e89372 | ||
|
|
8a62bc9237 | ||
|
|
471d94b6cd | ||
|
|
393ada0312 | ||
|
|
da160066c3 | ||
|
|
ff9427d463 | ||
|
|
3eb646eb0d | ||
|
|
578fe371c6 | ||
|
|
1b03a35fa1 | ||
|
|
ce736e7ba1 | ||
|
|
455508deac | ||
|
|
0a3af545fe | ||
|
|
dd92318762 | ||
|
|
c931619269 | ||
|
|
1be440a72b | ||
|
|
04c7d5c128 | ||
|
|
30c77b9e64 | ||
|
|
4fd4e84b72 | ||
|
|
d4c8024522 | ||
|
|
72379c166e | ||
|
|
f198706767 | ||
|
|
f0d534cebc | ||
|
|
47320adcc6 | ||
|
|
b9ed4b7a76 | ||
|
|
b71d65015a | ||
|
|
962358bf87 | ||
|
|
26a38f1fae | ||
|
|
83311df933 | ||
|
|
06285d1bf3 | ||
|
|
0aee355b14 | ||
|
|
b2b4712bb7 | ||
|
|
af96694430 | ||
|
|
df346feb65 | ||
|
|
08702548f3 | ||
|
|
bc69309b46 | ||
|
|
da0542e961 | ||
|
|
16e25f2039 | ||
|
|
3627de3e8a | ||
|
|
b31c52419d | ||
|
|
578a2cf357 | ||
|
|
69fd3aa856 | ||
|
|
12f222b5e3 | ||
|
|
eb317bd302 | ||
|
|
ab9d1a83af | ||
|
|
0e9e253b7b | ||
|
|
850caef5c1 | ||
|
|
8c0b50b5df | ||
|
|
a785a1ab5d | ||
|
|
3928d034a3 | ||
|
|
2680bf8a61 | ||
|
|
a8b5cc833d | ||
|
|
f54710c454 | ||
|
|
21197fb968 | ||
|
|
47d48c5990 | ||
|
|
38b09b1613 | ||
|
|
26dd490e8e | ||
|
|
1c99960357 | ||
|
|
3e1ab1b23a | ||
|
|
2a0c2d5247 | ||
|
|
b65bffd849 | ||
|
|
ab7c52a9c4 | ||
|
|
d6a4e106a9 | ||
|
|
a6511fc0b9 | ||
|
|
75b855ef93 | ||
|
|
d8a7e9ded8 | ||
|
|
f3d7cc66e5 | ||
|
|
b900005d1e | ||
|
|
8e9c73eb18 | ||
|
|
b024c3a833 | ||
|
|
31078b2b3e | ||
|
|
ad0e3cea8a | ||
|
|
b5e7e45f6c | ||
|
|
4486de743d | ||
|
|
df3c683023 | ||
|
|
d7a10136df | ||
|
|
c8d92ce907 | ||
|
|
111a3254fb | ||
|
|
d028236bf2 | ||
|
|
d0751ffd91 | ||
|
|
2fff0324f8 | ||
|
|
149eddaf46 | ||
|
|
acd2f55d4f | ||
|
|
4ef1bf2157 | ||
|
|
106cb63922 | ||
|
|
f6a79059e5 | ||
|
|
35690d5b29 | ||
|
|
3575c34f77 | ||
|
|
ee1c29b392 | ||
|
|
82d89edb4f | ||
|
|
475be636d6 | ||
|
|
f8218b5e01 | ||
|
|
79a9c1af9e | ||
|
|
6de0ed3f0a | ||
|
|
1d717b768d | ||
|
|
85c0de550c | ||
|
|
d2b62840f2 | ||
|
|
17c6ef5d54 | ||
|
|
3904d83c32 | ||
|
|
f3946cb54f | ||
|
|
832fa61477 | ||
|
|
5ae65142b8 | ||
|
|
eb584a26e2 | ||
|
|
87fb492b14 | ||
|
|
b9ad19acbf | ||
|
|
d1a621601d | ||
|
|
ae9e3d83d7 | ||
|
|
afa99915e3 | ||
|
|
bb13829e13 | ||
|
|
fb12294bb7 | ||
|
|
debae6ad2e | ||
|
|
eec4564c71 | ||
|
|
08dbd792cd | ||
|
|
b7e2522083 | ||
|
|
a62fc7ca04 | ||
|
|
0a68cae507 | ||
|
|
a10cbadb57 | ||
|
|
bbb40fde84 | ||
|
|
ce218b172a | ||
|
|
2e4e673bbe | ||
|
|
db4a0e3244 | ||
|
|
de82df3c6b | ||
|
|
3bc83920b4 | ||
|
|
253dc66129 | ||
|
|
b063547138 | ||
|
|
d8c6cb1112 | ||
|
|
5b0c12b12b | ||
|
|
ba372c085c | ||
|
|
2c36f4411e | ||
|
|
8eb9445bea | ||
|
|
456cec2931 | ||
|
|
af7fe8c4fd | ||
|
|
41ad04276b | ||
|
|
e591234b59 | ||
|
|
9f3c9cdb11 | ||
|
|
48b8fc9e01 | ||
|
|
4807ad7875 | ||
|
|
7b6893c9d3 | ||
|
|
4b85ffae4f | ||
|
|
2ca4893948 | ||
|
|
9156a827ce | ||
|
|
1dac84e9dd | ||
|
|
da715c2a03 | ||
|
|
fc1a4543d3 | ||
|
|
54904fb6c0 | ||
|
|
bd09e96681 | ||
|
|
8e84401b68 | ||
|
|
934eccfeee | ||
|
|
89bd6fa494 | ||
|
|
d8b9bee7fb | ||
|
|
558504c686 | ||
|
|
c69fe43e75 | ||
|
|
29f15393b1 | ||
|
|
c23792d1fb | ||
|
|
1ae58ce48b | ||
|
|
ecca51b16b | ||
|
|
3a854f4c05 | ||
|
|
c24ddfb1be | ||
|
|
0754a63969 | ||
|
|
8a75bee82f | ||
|
|
df21dd21f2 | ||
|
|
bac48aa9d2 | ||
|
|
53cbb28926 | ||
|
|
d7809c5398 | ||
|
|
9b3373a15b | ||
|
|
474909b515 | ||
|
|
80f2c2b124 | ||
|
|
ada148eeae | ||
|
|
449cde5396 | ||
|
|
d014517ce2 | ||
|
|
8f50180598 | ||
|
|
1686f73749 | ||
|
|
deb9a1133c | ||
|
|
e0f0487ce2 | ||
|
|
44e35ec9a1 | ||
|
|
a9990c130d | ||
|
|
fcdb25eb3c | ||
|
|
4bee3f760f | ||
|
|
5f53627c0a | ||
|
|
22f27b8621 | ||
|
|
a9dc4ba297 | ||
|
|
3701c0f219 | ||
|
|
a035725c67 | ||
|
|
440614dd9d | ||
|
|
163c881ced | ||
|
|
0467d0563a | ||
|
|
2b52f27eb9 | ||
|
|
31d7221c90 | ||
|
|
d9124b182a | ||
|
|
f2b818658f | ||
|
|
5a6ac9ee72 | ||
|
|
7fa5f07218 | ||
|
|
fa9a200e3c | ||
|
|
0ca67bf6f7 | ||
|
|
f1c5e756ff | ||
|
|
ff33d34b81 | ||
|
|
601389302a | ||
|
|
2ba521caf8 | ||
|
|
6f7ff9a18a | ||
|
|
4bc9e6dfe0 | ||
|
|
28215d7edd | ||
|
|
38ecf71307 | ||
|
|
4e272624eb | ||
|
|
ab4d0a7fc3 | ||
|
|
ca74f5efde | ||
|
|
e50a6ef8af | ||
|
|
5c026b1fa2 | ||
|
|
474567e762 | ||
|
|
16911a5cb4 | ||
|
|
46389fb6ca | ||
|
|
9aeb489282 | ||
|
|
8c9a39845c | ||
|
|
c976ac3b39 | ||
|
|
07a7ee0ac7 | ||
|
|
a306475065 | ||
|
|
faeaa43393 | ||
|
|
aadf72d445 | ||
|
|
48e28843e6 | ||
|
|
e06fa0d2d0 | ||
|
|
0bdf96d94c | ||
|
|
623cec206b | ||
|
|
a2386f871d | ||
|
|
5c3a4e3d10 | ||
|
|
a039c3209b | ||
|
|
fc8b1f4968 | ||
|
|
052d305243 | ||
|
|
43676fcaf4 | ||
|
|
093fa6f5e9 | ||
|
|
dd8544fdf8 | ||
|
|
02309cc318 | ||
|
|
2f07e92cc2 | ||
|
|
7b3b7d2eec | ||
|
|
5d5c78b374 | ||
|
|
eb2e2a116e | ||
|
|
392898e694 | ||
|
|
4d5338a1b0 | ||
|
|
87507c4b6f | ||
|
|
9d1b94c24a | ||
|
|
16e3ff2fec | ||
|
|
c1ed2f17ac | ||
|
|
1cbe080df9 | ||
|
|
61e0e11156 | ||
|
|
013e181497 | ||
|
|
9a25054a0d | ||
|
|
a03cb12c61 | ||
|
|
4a4ed128db | ||
|
|
6170065a2c | ||
|
|
4f2e7fc912 | ||
|
|
c2f8dfcb9f | ||
|
|
9d7b1fc3a7 | ||
|
|
7248c9cb0e | ||
|
|
b4e2f2a6ef | ||
|
|
9894eff732 | ||
|
|
1f123ebcc1 | ||
|
|
3c92aa9ecb | ||
|
|
7848381f43 | ||
|
|
4a661e351f | ||
|
|
b5b5bc2de8 | ||
|
|
d290ce3c9e | ||
|
|
2cbe083460 | ||
|
|
8b8629a5f4 | ||
|
|
f387cdec59 | ||
|
|
78b90be116 | ||
|
|
91c526d9fe | ||
|
|
f3ce463862 | ||
|
|
23f5d785c4 | ||
|
|
cd773455f0 | ||
|
|
5a5cbe4e72 | ||
|
|
ad2e8b3174 | ||
|
|
eb6b6ed87d | ||
|
|
00c9ca64c8 | ||
|
|
6f0a3b4b22 | ||
|
|
66f1643de5 | ||
|
|
50a30d4dc9 | ||
|
|
6ebdc7dabc | ||
|
|
d24ea7da90 | ||
|
|
5e18d52302 | ||
|
|
e41af133fc | ||
|
|
986ca23934 | ||
|
|
8771f9f7dd | ||
|
|
37327f6cbd | ||
|
|
4c04abfccc | ||
|
|
b198bb441a | ||
|
|
1c17b885db | ||
|
|
c0cf29aba9 | ||
|
|
92978b2f26 | ||
|
|
c99204149c | ||
|
|
98f159a039 | ||
|
|
bb37151987 | ||
|
|
af0f3fcbdb | ||
|
|
c7bfdbf3cf | ||
|
|
67aa76d295 | ||
|
|
cccc41c23e | ||
|
|
13144af65e | ||
|
|
b246fc977e | ||
|
|
e5d2900151 | ||
|
|
01ee03a9a1 | ||
|
|
7daf2caef2 | ||
|
|
9f36cebe59 | ||
|
|
22ab83acae | ||
|
|
1ad3c3b1e2 | ||
|
|
3d178708fc | ||
|
|
1341ecd2eb | ||
|
|
5b3e9399a9 |
49
.coveragerc
49
.coveragerc
@@ -73,7 +73,8 @@ omit =
|
||||
homeassistant/components/comfoconnect.py
|
||||
homeassistant/components/*/comfoconnect.py
|
||||
|
||||
homeassistant/components/daikin.py
|
||||
homeassistant/components/daikin/__init__.py
|
||||
homeassistant/components/daikin/const.py
|
||||
homeassistant/components/*/daikin.py
|
||||
|
||||
homeassistant/components/digital_ocean.py
|
||||
@@ -105,18 +106,24 @@ omit =
|
||||
homeassistant/components/enocean.py
|
||||
homeassistant/components/*/enocean.py
|
||||
|
||||
homeassistant/components/envisalink.py
|
||||
homeassistant/components/envisalink/__init__.py
|
||||
homeassistant/components/*/envisalink.py
|
||||
|
||||
homeassistant/components/evohome.py
|
||||
homeassistant/components/*/evohome.py
|
||||
|
||||
homeassistant/components/freebox.py
|
||||
homeassistant/components/*/freebox.py
|
||||
|
||||
homeassistant/components/fritzbox.py
|
||||
homeassistant/components/*/fritzbox.py
|
||||
|
||||
homeassistant/components/ecovacs.py
|
||||
homeassistant/components/*/ecovacs.py
|
||||
|
||||
homeassistant/components/esphome/__init__.py
|
||||
homeassistant/components/*/esphome.py
|
||||
|
||||
homeassistant/components/eufy.py
|
||||
homeassistant/components/*/eufy.py
|
||||
|
||||
@@ -148,6 +155,9 @@ omit =
|
||||
homeassistant/components/hive.py
|
||||
homeassistant/components/*/hive.py
|
||||
|
||||
homeassistant/components/hlk_sw16.py
|
||||
homeassistant/components/*/hlk_sw16.py
|
||||
|
||||
homeassistant/components/homekit_controller/__init__.py
|
||||
homeassistant/components/*/homekit_controller.py
|
||||
|
||||
@@ -157,6 +167,9 @@ omit =
|
||||
homeassistant/components/homematicip_cloud.py
|
||||
homeassistant/components/*/homematicip_cloud.py
|
||||
|
||||
homeassistant/components/homeworks.py
|
||||
homeassistant/components/*/homeworks.py
|
||||
|
||||
homeassistant/components/huawei_lte.py
|
||||
homeassistant/components/*/huawei_lte.py
|
||||
|
||||
@@ -200,9 +213,15 @@ omit =
|
||||
homeassistant/components/lametric.py
|
||||
homeassistant/components/*/lametric.py
|
||||
|
||||
homeassistant/components/lcn.py
|
||||
homeassistant/components/*/lcn.py
|
||||
|
||||
homeassistant/components/linode.py
|
||||
homeassistant/components/*/linode.py
|
||||
|
||||
homeassistant/components/lightwave.py
|
||||
homeassistant/components/*/lightwave.py
|
||||
|
||||
homeassistant/components/logi_circle.py
|
||||
homeassistant/components/*/logi_circle.py
|
||||
|
||||
@@ -259,6 +278,9 @@ omit =
|
||||
homeassistant/components/openuv/__init__.py
|
||||
homeassistant/components/*/openuv.py
|
||||
|
||||
homeassistant/components/plum_lightpad.py
|
||||
homeassistant/components/*/plum_lightpad.py
|
||||
|
||||
homeassistant/components/pilight.py
|
||||
homeassistant/components/*/pilight.py
|
||||
|
||||
@@ -281,6 +303,8 @@ omit =
|
||||
homeassistant/components/raspihats.py
|
||||
homeassistant/components/*/raspihats.py
|
||||
|
||||
homeassistant/components/*/raspyrfm.py
|
||||
|
||||
homeassistant/components/rfxtrx.py
|
||||
homeassistant/components/*/rfxtrx.py
|
||||
|
||||
@@ -323,7 +347,8 @@ omit =
|
||||
homeassistant/components/tahoma.py
|
||||
homeassistant/components/*/tahoma.py
|
||||
|
||||
homeassistant/components/tellduslive.py
|
||||
homeassistant/components/tellduslive/__init__.py
|
||||
homeassistant/components/tellduslive/entry.py
|
||||
homeassistant/components/*/tellduslive.py
|
||||
|
||||
homeassistant/components/tellstick.py
|
||||
@@ -400,6 +425,9 @@ omit =
|
||||
|
||||
homeassistant/components/zha/__init__.py
|
||||
homeassistant/components/zha/const.py
|
||||
homeassistant/components/zha/event.py
|
||||
homeassistant/components/zha/entities/*
|
||||
homeassistant/components/zha/helpers.py
|
||||
homeassistant/components/*/zha.py
|
||||
|
||||
homeassistant/components/zigbee.py
|
||||
@@ -414,6 +442,7 @@ omit =
|
||||
homeassistant/components/spider.py
|
||||
homeassistant/components/*/spider.py
|
||||
|
||||
homeassistant/components/air_quality/opensensemap.py
|
||||
homeassistant/components/alarm_control_panel/alarmdotcom.py
|
||||
homeassistant/components/alarm_control_panel/canary.py
|
||||
homeassistant/components/alarm_control_panel/concord232.py
|
||||
@@ -487,7 +516,6 @@ omit =
|
||||
homeassistant/components/device_tracker/bt_smarthub.py
|
||||
homeassistant/components/device_tracker/cisco_ios.py
|
||||
homeassistant/components/device_tracker/ddwrt.py
|
||||
homeassistant/components/device_tracker/freebox.py
|
||||
homeassistant/components/device_tracker/fritz.py
|
||||
homeassistant/components/device_tracker/google_maps.py
|
||||
homeassistant/components/device_tracker/googlehome.py
|
||||
@@ -524,6 +552,7 @@ omit =
|
||||
homeassistant/components/folder_watcher.py
|
||||
homeassistant/components/foursquare.py
|
||||
homeassistant/components/goalfeed.py
|
||||
homeassistant/components/idteck_prox.py
|
||||
homeassistant/components/ifttt.py
|
||||
homeassistant/components/image_processing/dlib_face_detect.py
|
||||
homeassistant/components/image_processing/dlib_face_identify.py
|
||||
@@ -587,6 +616,7 @@ omit =
|
||||
homeassistant/components/media_player/frontier_silicon.py
|
||||
homeassistant/components/media_player/gpmdp.py
|
||||
homeassistant/components/media_player/gstreamer.py
|
||||
homeassistant/components/media_player/harman_kardon_avr.py
|
||||
homeassistant/components/media_player/horizon.py
|
||||
homeassistant/components/media_player/itunes.py
|
||||
homeassistant/components/media_player/kodi.py
|
||||
@@ -637,7 +667,6 @@ omit =
|
||||
homeassistant/components/notify/group.py
|
||||
homeassistant/components/notify/hipchat.py
|
||||
homeassistant/components/notify/homematic.py
|
||||
homeassistant/components/notify/instapush.py
|
||||
homeassistant/components/notify/kodi.py
|
||||
homeassistant/components/notify/lannouncer.py
|
||||
homeassistant/components/notify/llamalab_automate.py
|
||||
@@ -672,8 +701,10 @@ omit =
|
||||
homeassistant/components/route53.py
|
||||
homeassistant/components/scene/hunterdouglas_powerview.py
|
||||
homeassistant/components/scene/lifx_cloud.py
|
||||
homeassistant/components/sensor/aftership.py
|
||||
homeassistant/components/sensor/airvisual.py
|
||||
homeassistant/components/sensor/alpha_vantage.py
|
||||
homeassistant/components/sensor/ambient_station.py
|
||||
homeassistant/components/sensor/arest.py
|
||||
homeassistant/components/sensor/arwn.py
|
||||
homeassistant/components/sensor/bbox.py
|
||||
@@ -684,6 +715,7 @@ omit =
|
||||
homeassistant/components/sensor/bme680.py
|
||||
homeassistant/components/sensor/bom.py
|
||||
homeassistant/components/sensor/broadlink.py
|
||||
homeassistant/components/sensor/brottsplatskartan.py
|
||||
homeassistant/components/sensor/buienradar.py
|
||||
homeassistant/components/sensor/cert_expiry.py
|
||||
homeassistant/components/sensor/citybikes.py
|
||||
@@ -729,6 +761,7 @@ omit =
|
||||
homeassistant/components/sensor/google_travel_time.py
|
||||
homeassistant/components/sensor/gpsd.py
|
||||
homeassistant/components/sensor/gtfs.py
|
||||
homeassistant/components/sensor/gtt.py
|
||||
homeassistant/components/sensor/haveibeenpwned.py
|
||||
homeassistant/components/sensor/hp_ilo.py
|
||||
homeassistant/components/sensor/htu21d.py
|
||||
@@ -744,6 +777,7 @@ omit =
|
||||
homeassistant/components/sensor/launch_library.py
|
||||
homeassistant/components/sensor/linky.py
|
||||
homeassistant/components/sensor/linux_battery.py
|
||||
homeassistant/components/sensor/london_underground.py
|
||||
homeassistant/components/sensor/loopenergy.py
|
||||
homeassistant/components/sensor/luftdaten.py
|
||||
homeassistant/components/sensor/lyft.py
|
||||
@@ -761,6 +795,7 @@ omit =
|
||||
homeassistant/components/sensor/netdata.py
|
||||
homeassistant/components/sensor/netdata_public.py
|
||||
homeassistant/components/sensor/neurio_energy.py
|
||||
homeassistant/components/sensor/nmbs.py
|
||||
homeassistant/components/sensor/noaa_tides.py
|
||||
homeassistant/components/sensor/nsw_fuel_station.py
|
||||
homeassistant/components/sensor/nut.py
|
||||
@@ -777,9 +812,11 @@ omit =
|
||||
homeassistant/components/sensor/pocketcasts.py
|
||||
homeassistant/components/sensor/pollen.py
|
||||
homeassistant/components/sensor/postnl.py
|
||||
homeassistant/components/sensor/prezzibenzina.py
|
||||
homeassistant/components/sensor/pushbullet.py
|
||||
homeassistant/components/sensor/pvoutput.py
|
||||
homeassistant/components/sensor/pyload.py
|
||||
homeassistant/components/sensor/qbittorrent.py
|
||||
homeassistant/components/sensor/qnap.py
|
||||
homeassistant/components/sensor/radarr.py
|
||||
homeassistant/components/sensor/rainbird.py
|
||||
@@ -800,6 +837,7 @@ omit =
|
||||
homeassistant/components/sensor/snmp.py
|
||||
homeassistant/components/sensor/sochain.py
|
||||
homeassistant/components/sensor/socialblade.py
|
||||
homeassistant/components/sensor/solaredge.py
|
||||
homeassistant/components/sensor/sonarr.py
|
||||
homeassistant/components/sensor/speedtest.py
|
||||
homeassistant/components/sensor/spotcrime.py
|
||||
@@ -855,6 +893,7 @@ omit =
|
||||
homeassistant/components/switch/mystrom.py
|
||||
homeassistant/components/switch/netio.py
|
||||
homeassistant/components/switch/orvibo.py
|
||||
homeassistant/components/switch/pencom.py
|
||||
homeassistant/components/switch/pulseaudio_loopback.py
|
||||
homeassistant/components/switch/rainbird.py
|
||||
homeassistant/components/switch/rest.py
|
||||
|
||||
1
.github/ISSUE_TEMPLATE.md
vendored
1
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +1,7 @@
|
||||
<!-- READ THIS FIRST:
|
||||
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
|
||||
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/home-assistant/releases
|
||||
- Frontend issues should be submitted to the home-assistant-polymer repository: https://github.com/home-assistant/home-assistant-polymer/issues
|
||||
- Do not report issues for components if you are using custom components: files in <config-dir>/custom_components
|
||||
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
|
||||
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -7,6 +7,7 @@ about: Create a report to help us improve
|
||||
<!-- READ THIS FIRST:
|
||||
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
|
||||
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/home-assistant/releases
|
||||
- Frontend issues should be submitted to the home-assistant-polymer repository: https://github.com/home-assistant/home-assistant-polymer/issues
|
||||
- Do not report issues for components if you are using custom components: files in <config-dir>/custom_components
|
||||
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
|
||||
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
|
||||
|
||||
25
CODEOWNERS
25
CODEOWNERS
@@ -51,6 +51,7 @@ homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt
|
||||
homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell
|
||||
homeassistant/components/binary_sensor/hikvision.py @mezz64
|
||||
homeassistant/components/binary_sensor/threshold.py @fabaff
|
||||
homeassistant/components/binary_sensor/uptimerobot.py @ludeeus
|
||||
homeassistant/components/camera/yi.py @bachya
|
||||
homeassistant/components/climate/ephember.py @ttroy50
|
||||
homeassistant/components/climate/eq3btsmart.py @rytilahti
|
||||
@@ -61,9 +62,11 @@ homeassistant/components/cover/group.py @cdce8p
|
||||
homeassistant/components/cover/template.py @PhracturedBlue
|
||||
homeassistant/components/device_tracker/asuswrt.py @kennedyshead
|
||||
homeassistant/components/device_tracker/automatic.py @armills
|
||||
homeassistant/components/device_tracker/googlehome.py @ludeeus
|
||||
homeassistant/components/device_tracker/huawei_router.py @abmantis
|
||||
homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan
|
||||
homeassistant/components/device_tracker/tile.py @bachya
|
||||
homeassistant/components/device_tracker/traccar.py @ludeeus
|
||||
homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme
|
||||
homeassistant/components/history_graph.py @andrey-git
|
||||
homeassistant/components/influx.py @fabaff
|
||||
@@ -109,6 +112,7 @@ homeassistant/components/sensor/glances.py @fabaff
|
||||
homeassistant/components/sensor/gpsd.py @fabaff
|
||||
homeassistant/components/sensor/irish_rail_transport.py @ttroy50
|
||||
homeassistant/components/sensor/jewish_calendar.py @tsvi
|
||||
homeassistant/components/sensor/launch_library.py @ludeeus
|
||||
homeassistant/components/sensor/linux_battery.py @fabaff
|
||||
homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel
|
||||
homeassistant/components/sensor/min_max.py @fabaff
|
||||
@@ -119,6 +123,7 @@ homeassistant/components/sensor/pi_hole.py @fabaff
|
||||
homeassistant/components/sensor/pollen.py @bachya
|
||||
homeassistant/components/sensor/pvoutput.py @fabaff
|
||||
homeassistant/components/sensor/qnap.py @colinodell
|
||||
homeassistant/components/sensor/ruter.py @ludeeus
|
||||
homeassistant/components/sensor/scrape.py @fabaff
|
||||
homeassistant/components/sensor/serial.py @fabaff
|
||||
homeassistant/components/sensor/seventeentrack.py @bachya
|
||||
@@ -128,12 +133,15 @@ homeassistant/components/sensor/sql.py @dgomes
|
||||
homeassistant/components/sensor/statistics.py @fabaff
|
||||
homeassistant/components/sensor/swiss*.py @fabaff
|
||||
homeassistant/components/sensor/sytadin.py @gautric
|
||||
homeassistant/components/sensor/tautulli.py @ludeeus
|
||||
homeassistant/components/sensor/time_data.py @fabaff
|
||||
homeassistant/components/sensor/version.py @fabaff
|
||||
homeassistant/components/sensor/waqi.py @andrey-git
|
||||
homeassistant/components/sensor/worldclock.py @fabaff
|
||||
homeassistant/components/shiftr.py @fabaff
|
||||
homeassistant/components/spaceapi.py @fabaff
|
||||
homeassistant/components/switch/switchbot.py @danielhiversen
|
||||
homeassistant/components/switch/switchmate.py @danielhiversen
|
||||
homeassistant/components/switch/tplink.py @rytilahti
|
||||
homeassistant/components/vacuum/roomba.py @pschmitt
|
||||
homeassistant/components/weather/__init__.py @fabaff
|
||||
@@ -157,9 +165,12 @@ homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/*/broadlink.py @danielhiversen
|
||||
|
||||
# C
|
||||
homeassistant/components/cloudflare.py @ludeeus
|
||||
homeassistant/components/counter/* @fabaff
|
||||
|
||||
# D
|
||||
homeassistant/components/daikin.py @fredrike @rofrantz
|
||||
homeassistant/components/*/daikin.py @fredrike @rofrantz
|
||||
homeassistant/components/*/deconz.py @kane610
|
||||
homeassistant/components/digital_ocean.py @fabaff
|
||||
homeassistant/components/*/digital_ocean.py @fabaff
|
||||
@@ -173,6 +184,8 @@ homeassistant/components/*/edp_redy.py @abmantis
|
||||
homeassistant/components/edp_redy.py @abmantis
|
||||
homeassistant/components/eight_sleep.py @mezz64
|
||||
homeassistant/components/*/eight_sleep.py @mezz64
|
||||
homeassistant/components/esphome/*.py @OttoWinter
|
||||
homeassistant/components/*/esphome.py @OttoWinter
|
||||
|
||||
# H
|
||||
homeassistant/components/hive.py @Rendili @KJonline
|
||||
@@ -200,10 +213,18 @@ homeassistant/components/melissa.py @kennedyshead
|
||||
homeassistant/components/*/melissa.py @kennedyshead
|
||||
homeassistant/components/*/mystrom.py @fabaff
|
||||
|
||||
# N
|
||||
homeassistant/components/ness_alarm.py @nickw444
|
||||
homeassistant/components/*/ness_alarm.py @nickw444
|
||||
|
||||
# O
|
||||
homeassistant/components/openuv/* @bachya
|
||||
homeassistant/components/*/openuv.py @bachya
|
||||
|
||||
# P
|
||||
homeassistant/components/point/* @fredrike
|
||||
homeassistant/components/*/point.py @fredrike
|
||||
|
||||
# Q
|
||||
homeassistant/components/qwikswitch.py @kellerza
|
||||
homeassistant/components/*/qwikswitch.py @kellerza
|
||||
@@ -221,8 +242,8 @@ homeassistant/components/*/simplisafe.py @bachya
|
||||
# T
|
||||
homeassistant/components/tahoma.py @philklei
|
||||
homeassistant/components/*/tahoma.py @philklei
|
||||
homeassistant/components/tellduslive.py @molobrakos @fredrike
|
||||
homeassistant/components/*/tellduslive.py @molobrakos @fredrike
|
||||
homeassistant/components/tellduslive/*.py @fredrike
|
||||
homeassistant/components/*/tellduslive.py @fredrike
|
||||
homeassistant/components/tesla.py @zabuldon
|
||||
homeassistant/components/*/tesla.py @zabuldon
|
||||
homeassistant/components/thethingsnetwork.py @fabaff
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Home Assistant |Build Status| |Coverage Status| |Chat Status| |Reviewed by Hound|
|
||||
Home Assistant |Build Status| |Coverage Status| |Chat Status|
|
||||
=================================================================================
|
||||
|
||||
Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control.
|
||||
@@ -33,8 +33,6 @@ of a component, check the `Home Assistant help section <https://home-assistant.i
|
||||
:target: https://coveralls.io/r/home-assistant/home-assistant?branch=master
|
||||
.. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg
|
||||
:target: https://discord.gg/c5DvZ4e
|
||||
.. |Reviewed by Hound| image:: https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg
|
||||
:target: https://houndci.com
|
||||
.. |screenshot-states| image:: https://raw.github.com/home-assistant/home-assistant/master/docs/screenshots.png
|
||||
:target: https://home-assistant.io/demo/
|
||||
.. |screenshot-components| image:: https://raw.github.com/home-assistant/home-assistant/dev/docs/screenshot-components.png
|
||||
|
||||
@@ -78,11 +78,6 @@ class AuthManager:
|
||||
hass, self._async_create_login_flow,
|
||||
self._async_finish_login_flow)
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Return if any auth providers are registered."""
|
||||
return bool(self._providers)
|
||||
|
||||
@property
|
||||
def support_legacy(self) -> bool:
|
||||
"""
|
||||
@@ -190,6 +185,7 @@ class AuthManager:
|
||||
credentials=credentials,
|
||||
name=info.name,
|
||||
is_active=info.is_active,
|
||||
group_ids=[GROUP_ID_ADMIN],
|
||||
)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_USER_ADDED, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Storage for auth models."""
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
import hmac
|
||||
@@ -11,7 +12,7 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import models
|
||||
from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
|
||||
from .permissions import system_policies
|
||||
from .permissions import PermissionLookup, system_policies
|
||||
from .permissions.types import PolicyType # noqa: F401
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
@@ -34,6 +35,7 @@ class AuthStore:
|
||||
self.hass = hass
|
||||
self._users = None # type: Optional[Dict[str, models.User]]
|
||||
self._groups = None # type: Optional[Dict[str, models.Group]]
|
||||
self._perm_lookup = None # type: Optional[PermissionLookup]
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY,
|
||||
private=True)
|
||||
|
||||
@@ -94,6 +96,7 @@ class AuthStore:
|
||||
# Until we get group management, we just put everyone in the
|
||||
# same group.
|
||||
'groups': groups,
|
||||
'perm_lookup': self._perm_lookup,
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if is_owner is not None:
|
||||
@@ -269,13 +272,18 @@ class AuthStore:
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
"""Load the users."""
|
||||
data = await self._store.async_load()
|
||||
[ent_reg, data] = await asyncio.gather(
|
||||
self.hass.helpers.entity_registry.async_get_registry(),
|
||||
self._store.async_load(),
|
||||
)
|
||||
|
||||
# Make sure that we're not overriding data if 2 loads happened at the
|
||||
# same time
|
||||
if self._users is not None:
|
||||
return
|
||||
|
||||
self._perm_lookup = perm_lookup = PermissionLookup(ent_reg)
|
||||
|
||||
if data is None:
|
||||
self._set_defaults()
|
||||
return
|
||||
@@ -374,6 +382,7 @@ class AuthStore:
|
||||
is_owner=user_dict['is_owner'],
|
||||
is_active=user_dict['is_active'],
|
||||
system_generated=user_dict['system_generated'],
|
||||
perm_lookup=perm_lookup,
|
||||
)
|
||||
|
||||
for cred_dict in data['credentials']:
|
||||
@@ -462,10 +471,11 @@ class AuthStore:
|
||||
for group in self._groups.values():
|
||||
g_dict = {
|
||||
'id': group.id,
|
||||
# Name not read for sys groups. Kept here for backwards compat
|
||||
'name': group.name
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN):
|
||||
g_dict['name'] = group.name
|
||||
g_dict['policy'] = group.policy
|
||||
|
||||
groups.append(g_dict)
|
||||
|
||||
@@ -4,13 +4,14 @@ Sending HOTP through notify service
|
||||
"""
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, Optional, Tuple, List # noqa: F401
|
||||
from typing import Any, Dict, Optional, List
|
||||
|
||||
import attr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
|
||||
@@ -314,8 +315,11 @@ class NotifySetupFlow(SetupFlow):
|
||||
_generate_otp, self._secret, self._count)
|
||||
|
||||
assert self._notify_service
|
||||
await self._auth_module.async_notify(
|
||||
code, self._notify_service, self._target)
|
||||
try:
|
||||
await self._auth_module.async_notify(
|
||||
code, self._notify_service, self._target)
|
||||
except ServiceNotFound:
|
||||
return self.async_abort(reason='notify_service_not_exist')
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='setup',
|
||||
|
||||
@@ -31,6 +31,9 @@ class User:
|
||||
"""A user."""
|
||||
|
||||
name = attr.ib(type=str) # type: Optional[str]
|
||||
perm_lookup = attr.ib(
|
||||
type=perm_mdl.PermissionLookup, cmp=False,
|
||||
) # type: perm_mdl.PermissionLookup
|
||||
id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
|
||||
is_owner = attr.ib(type=bool, default=False)
|
||||
is_active = attr.ib(type=bool, default=False)
|
||||
@@ -66,7 +69,8 @@ class User:
|
||||
|
||||
self._permissions = perm_mdl.PolicyPermissions(
|
||||
perm_mdl.merge_policies([
|
||||
group.policy for group in self.groups]))
|
||||
group.policy for group in self.groups]),
|
||||
self.perm_lookup)
|
||||
|
||||
return self._permissions
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"""Permissions for Home Assistant."""
|
||||
import logging
|
||||
from typing import ( # noqa: F401
|
||||
cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union)
|
||||
cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union,
|
||||
TYPE_CHECKING)
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import CAT_ENTITIES
|
||||
from .models import PermissionLookup
|
||||
from .types import PolicyType
|
||||
from .entities import ENTITY_POLICY_SCHEMA, compile_entities
|
||||
from .merge import merge_policies # noqa
|
||||
|
||||
|
||||
POLICY_SCHEMA = vol.Schema({
|
||||
vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA
|
||||
})
|
||||
@@ -39,13 +42,16 @@ class AbstractPermissions:
|
||||
class PolicyPermissions(AbstractPermissions):
|
||||
"""Handle permissions."""
|
||||
|
||||
def __init__(self, policy: PolicyType) -> None:
|
||||
def __init__(self, policy: PolicyType,
|
||||
perm_lookup: PermissionLookup) -> None:
|
||||
"""Initialize the permission class."""
|
||||
self._policy = policy
|
||||
self._perm_lookup = perm_lookup
|
||||
|
||||
def _entity_func(self) -> Callable[[str, str], bool]:
|
||||
"""Return a function that can test entity access."""
|
||||
return compile_entities(self._policy.get(CAT_ENTITIES))
|
||||
return compile_entities(self._policy.get(CAT_ENTITIES),
|
||||
self._perm_lookup)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""Equals check."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Permission constants."""
|
||||
CAT_ENTITIES = 'entities'
|
||||
CAT_CONFIG_ENTRIES = 'config_entries'
|
||||
SUBCAT_ALL = 'all'
|
||||
|
||||
POLICY_READ = 'read'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Entity permissions."""
|
||||
from functools import wraps
|
||||
from typing import ( # noqa: F401
|
||||
Callable, Dict, List, Tuple, Union)
|
||||
from typing import Callable, List, Union # noqa: F401
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT
|
||||
from .models import PermissionLookup
|
||||
from .types import CategoryType, ValueType
|
||||
|
||||
SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({
|
||||
@@ -15,6 +15,7 @@ SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({
|
||||
}))
|
||||
|
||||
ENTITY_DOMAINS = 'domains'
|
||||
ENTITY_DEVICE_IDS = 'device_ids'
|
||||
ENTITY_ENTITY_IDS = 'entity_ids'
|
||||
|
||||
ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({
|
||||
@@ -23,6 +24,7 @@ ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({
|
||||
|
||||
ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({
|
||||
vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA,
|
||||
vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA,
|
||||
vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA,
|
||||
vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA,
|
||||
}))
|
||||
@@ -37,7 +39,7 @@ def _entity_allowed(schema: ValueType, key: str) \
|
||||
return schema.get(key)
|
||||
|
||||
|
||||
def compile_entities(policy: CategoryType) \
|
||||
def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \
|
||||
-> Callable[[str, str], bool]:
|
||||
"""Compile policy into a function that tests policy."""
|
||||
# None, Empty Dict, False
|
||||
@@ -58,6 +60,7 @@ def compile_entities(policy: CategoryType) \
|
||||
assert isinstance(policy, dict)
|
||||
|
||||
domains = policy.get(ENTITY_DOMAINS)
|
||||
device_ids = policy.get(ENTITY_DEVICE_IDS)
|
||||
entity_ids = policy.get(ENTITY_ENTITY_IDS)
|
||||
all_entities = policy.get(SUBCAT_ALL)
|
||||
|
||||
@@ -85,6 +88,29 @@ def compile_entities(policy: CategoryType) \
|
||||
|
||||
funcs.append(allowed_entity_id_dict)
|
||||
|
||||
if isinstance(device_ids, bool):
|
||||
def allowed_device_id_bool(entity_id: str, key: str) \
|
||||
-> Union[None, bool]:
|
||||
"""Test if allowed device_id."""
|
||||
return device_ids
|
||||
|
||||
funcs.append(allowed_device_id_bool)
|
||||
|
||||
elif device_ids is not None:
|
||||
def allowed_device_id_dict(entity_id: str, key: str) \
|
||||
-> Union[None, bool]:
|
||||
"""Test if allowed device_id."""
|
||||
entity_entry = perm_lookup.entity_registry.async_get(entity_id)
|
||||
|
||||
if entity_entry is None or entity_entry.device_id is None:
|
||||
return None
|
||||
|
||||
return _entity_allowed(
|
||||
device_ids.get(entity_entry.device_id), key # type: ignore
|
||||
)
|
||||
|
||||
funcs.append(allowed_device_id_dict)
|
||||
|
||||
if isinstance(domains, bool):
|
||||
def allowed_domain_bool(entity_id: str, key: str) \
|
||||
-> Union[None, bool]:
|
||||
|
||||
17
homeassistant/auth/permissions/models.py
Normal file
17
homeassistant/auth/permissions/models.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Models for permissions."""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
from homeassistant.helpers import ( # noqa
|
||||
entity_registry as ent_reg,
|
||||
)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class PermissionLookup:
|
||||
"""Class to hold data for permission lookups."""
|
||||
|
||||
entity_registry = attr.ib(type='ent_reg.EntityRegistry')
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Common code for permissions."""
|
||||
from typing import ( # noqa: F401
|
||||
Mapping, Union, Any)
|
||||
from typing import Mapping, Union
|
||||
|
||||
# MyPy doesn't support recursion yet. So writing it out as far as we need.
|
||||
|
||||
|
||||
@@ -226,7 +226,11 @@ class LoginFlow(data_entry_flow.FlowHandler):
|
||||
|
||||
if user_input is None and hasattr(auth_module,
|
||||
'async_initialize_login_mfa_step'):
|
||||
await auth_module.async_initialize_login_mfa_step(self.user.id)
|
||||
try:
|
||||
await auth_module.async_initialize_login_mfa_step(self.user.id)
|
||||
except HomeAssistantError:
|
||||
_LOGGER.exception('Error initializing MFA step')
|
||||
return self.async_abort(reason='unknown_error')
|
||||
|
||||
if user_input is not None:
|
||||
expires = self.created_at + MFA_SESSION_EXPIRATION
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Home Assistant auth provider."""
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
import bcrypt
|
||||
@@ -11,12 +9,10 @@ import voluptuous as vol
|
||||
from homeassistant.const import CONF_ID
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util.async_ import run_coroutine_threadsafe
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
|
||||
from ..models import Credentials, UserMeta
|
||||
from ..util import generate_secret
|
||||
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
@@ -62,7 +58,6 @@ class Data:
|
||||
|
||||
if data is None:
|
||||
data = {
|
||||
'salt': generate_secret(),
|
||||
'users': []
|
||||
}
|
||||
|
||||
@@ -94,39 +89,11 @@ class Data:
|
||||
|
||||
user_hash = base64.b64decode(found['password'])
|
||||
|
||||
# if the hash is not a bcrypt hash...
|
||||
# provide a transparant upgrade for old pbkdf2 hash format
|
||||
if not (user_hash.startswith(b'$2a$')
|
||||
or user_hash.startswith(b'$2b$')
|
||||
or user_hash.startswith(b'$2x$')
|
||||
or user_hash.startswith(b'$2y$')):
|
||||
# IMPORTANT! validate the login, bail if invalid
|
||||
hashed = self.legacy_hash_password(password)
|
||||
if not hmac.compare_digest(hashed, user_hash):
|
||||
raise InvalidAuth
|
||||
# then re-hash the valid password with bcrypt
|
||||
self.change_password(found['username'], password)
|
||||
run_coroutine_threadsafe(
|
||||
self.async_save(), self.hass.loop
|
||||
).result()
|
||||
user_hash = base64.b64decode(found['password'])
|
||||
|
||||
# bcrypt.checkpw is timing-safe
|
||||
if not bcrypt.checkpw(password.encode(),
|
||||
user_hash):
|
||||
raise InvalidAuth
|
||||
|
||||
def legacy_hash_password(self, password: str,
|
||||
for_storage: bool = False) -> bytes:
|
||||
"""LEGACY password encoding."""
|
||||
# We're no longer storing salts in data, but if one exists we
|
||||
# should be able to retrieve it.
|
||||
salt = self._data['salt'].encode() # type: ignore
|
||||
hashed = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000)
|
||||
if for_storage:
|
||||
hashed = base64.b64encode(hashed)
|
||||
return hashed
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
||||
"""Encode a password."""
|
||||
|
||||
@@ -4,16 +4,19 @@ Support Legacy API password auth provider.
|
||||
It will be removed when auth system production ready
|
||||
"""
|
||||
import hmac
|
||||
from typing import Any, Dict, Optional, cast
|
||||
from typing import Any, Dict, Optional, cast, TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
from .. import AuthManager
|
||||
from ..models import Credentials, UserMeta, User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
|
||||
|
||||
USER_SCHEMA = vol.Schema({
|
||||
@@ -31,6 +34,24 @@ class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
async def async_get_user(hass: HomeAssistant) -> User:
|
||||
"""Return the legacy API password user."""
|
||||
auth = cast(AuthManager, hass.auth) # type: ignore
|
||||
found = None
|
||||
|
||||
for prv in auth.auth_providers:
|
||||
if prv.type == 'legacy_api_password':
|
||||
found = prv
|
||||
break
|
||||
|
||||
if found is None:
|
||||
raise ValueError('Legacy API password provider not found')
|
||||
|
||||
return await auth.async_get_or_create_user(
|
||||
await found.async_get_or_create_credentials({})
|
||||
)
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register('legacy_api_password')
|
||||
class LegacyApiPasswordAuthProvider(AuthProvider):
|
||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
||||
|
||||
@@ -115,11 +115,6 @@ async def async_from_config_dict(config: Dict[str, Any],
|
||||
conf_util.merge_packages_config(
|
||||
hass, config, core_config.get(conf_util.CONF_PACKAGES, {}))
|
||||
|
||||
# Ensure we have no None values after merge
|
||||
for key, value in config.items():
|
||||
if not value:
|
||||
config[key] = {}
|
||||
|
||||
hass.config_entries = config_entries.ConfigEntries(hass, config)
|
||||
await hass.config_entries.async_load()
|
||||
|
||||
|
||||
@@ -125,16 +125,23 @@ class AdsHub:
|
||||
|
||||
def shutdown(self, *args, **kwargs):
|
||||
"""Shutdown ADS connection."""
|
||||
import pyads
|
||||
_LOGGER.debug("Shutting down ADS")
|
||||
for notification_item in self._notification_items.values():
|
||||
self._client.del_device_notification(
|
||||
notification_item.hnotify,
|
||||
notification_item.huser
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Deleting device notification %d, %d",
|
||||
notification_item.hnotify, notification_item.huser)
|
||||
self._client.close()
|
||||
try:
|
||||
self._client.del_device_notification(
|
||||
notification_item.hnotify,
|
||||
notification_item.huser
|
||||
)
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error(err)
|
||||
try:
|
||||
self._client.close()
|
||||
except pyads.ADSError as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
def register_device(self, device):
|
||||
"""Register a new device."""
|
||||
|
||||
147
homeassistant/components/air_quality/__init__.py
Normal file
147
homeassistant/components/air_quality/__init__.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Component for handling Air Quality data for your location.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/air_quality/
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_AQI = 'air_quality_index'
|
||||
ATTR_ATTRIBUTION = 'attribution'
|
||||
ATTR_C02 = 'carbon_dioxide'
|
||||
ATTR_CO = 'carbon_monoxide'
|
||||
ATTR_N2O = 'nitrogen_oxide'
|
||||
ATTR_NO = 'nitrogen_monoxide'
|
||||
ATTR_NO2 = 'nitrogen_dioxide'
|
||||
ATTR_OZONE = 'ozone'
|
||||
ATTR_PM_0_1 = 'particulate_matter_0_1'
|
||||
ATTR_PM_10 = 'particulate_matter_10'
|
||||
ATTR_PM_2_5 = 'particulate_matter_2_5'
|
||||
ATTR_SO2 = 'sulphur_dioxide'
|
||||
|
||||
DOMAIN = 'air_quality'
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
PROP_TO_ATTR = {
|
||||
'air_quality_index': ATTR_AQI,
|
||||
'attribution': ATTR_ATTRIBUTION,
|
||||
'carbon_dioxide': ATTR_C02,
|
||||
'carbon_monoxide': ATTR_CO,
|
||||
'nitrogen_oxide': ATTR_N2O,
|
||||
'nitrogen_monoxide': ATTR_NO,
|
||||
'nitrogen_dioxide': ATTR_NO2,
|
||||
'ozone': ATTR_OZONE,
|
||||
'particulate_matter_0_1': ATTR_PM_0_1,
|
||||
'particulate_matter_10': ATTR_PM_10,
|
||||
'particulate_matter_2_5': ATTR_PM_2_5,
|
||||
'sulphur_dioxide': ATTR_SO2,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the air quality component."""
|
||||
component = hass.data[DOMAIN] = EntityComponent(
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
|
||||
await component.async_setup(config)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DOMAIN].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DOMAIN].async_unload_entry(entry)
|
||||
|
||||
|
||||
class AirQualityEntity(Entity):
|
||||
"""ABC for air quality data."""
|
||||
|
||||
@property
|
||||
def particulate_matter_2_5(self):
|
||||
"""Return the particulate matter 2.5 level."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def particulate_matter_10(self):
|
||||
"""Return the particulate matter 10 level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def particulate_matter_0_1(self):
|
||||
"""Return the particulate matter 0.1 level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def air_quality_index(self):
|
||||
"""Return the Air Quality Index (AQI)."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def ozone(self):
|
||||
"""Return the O3 (ozone) level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def carbon_monoxide(self):
|
||||
"""Return the CO (carbon monoxide) level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def carbon_dioxide(self):
|
||||
"""Return the CO2 (carbon dioxide) level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def attribution(self):
|
||||
"""Return the attribution."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def sulphur_dioxide(self):
|
||||
"""Return the SO2 (sulphur dioxide) level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def nitrogen_oxide(self):
|
||||
"""Return the N2O (nitrogen oxide) level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def nitrogen_monoxide(self):
|
||||
"""Return the NO (nitrogen monoxide) level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def nitrogen_dioxide(self):
|
||||
"""Return the NO2 (nitrogen dioxide) level."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
data = {}
|
||||
|
||||
for prop, attr in PROP_TO_ATTR.items():
|
||||
value = getattr(self, prop)
|
||||
if value is not None:
|
||||
data[attr] = value
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the current state."""
|
||||
return self.particulate_matter_2_5
|
||||
56
homeassistant/components/air_quality/demo.py
Normal file
56
homeassistant/components/air_quality/demo.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Demo platform that offers fake air quality data.
|
||||
|
||||
For more details about this platform, please refer to the documentation
|
||||
https://home-assistant.io/components/demo/
|
||||
"""
|
||||
from homeassistant.components.air_quality import AirQualityEntity
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Air Quality."""
|
||||
add_entities([
|
||||
DemoAirQuality('Home', 14, 23, 100),
|
||||
DemoAirQuality('Office', 4, 16, None)
|
||||
])
|
||||
|
||||
|
||||
class DemoAirQuality(AirQualityEntity):
|
||||
"""Representation of Air Quality data."""
|
||||
|
||||
def __init__(self, name, pm_2_5, pm_10, n2o):
|
||||
"""Initialize the Demo Air Quality."""
|
||||
self._name = name
|
||||
self._pm_2_5 = pm_2_5
|
||||
self._pm_10 = pm_10
|
||||
self._n2o = n2o
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return '{} {}'.format('Demo Air Quality', self._name)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed for Demo Air Quality."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def particulate_matter_2_5(self):
|
||||
"""Return the particulate matter 2.5 level."""
|
||||
return self._pm_2_5
|
||||
|
||||
@property
|
||||
def particulate_matter_10(self):
|
||||
"""Return the particulate matter 10 level."""
|
||||
return self._pm_10
|
||||
|
||||
@property
|
||||
def nitrogen_oxide(self):
|
||||
"""Return the nitrogen oxide (N2O) level."""
|
||||
return self._n2o
|
||||
|
||||
@property
|
||||
def attribution(self):
|
||||
"""Return the attribution."""
|
||||
return 'Powered by Home Assistant'
|
||||
105
homeassistant/components/air_quality/opensensemap.py
Normal file
105
homeassistant/components/air_quality/opensensemap.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Support for openSenseMap Air Quality data.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/air_quality/opensensemap/
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.air_quality import (
|
||||
PLATFORM_SCHEMA, AirQualityEntity)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = ['opensensemap-api==0.1.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTRIBUTION = 'Data provided by openSenseMap'
|
||||
|
||||
CONF_STATION_ID = 'station_id'
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_STATION_ID): cv.string,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
})
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the openSenseMap air quality platform."""
|
||||
from opensensemap_api import OpenSenseMap
|
||||
|
||||
name = config.get(CONF_NAME)
|
||||
station_id = config[CONF_STATION_ID]
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
osm_api = OpenSenseMapData(OpenSenseMap(station_id, hass.loop, session))
|
||||
|
||||
await osm_api.async_update()
|
||||
|
||||
if 'name' not in osm_api.api.data:
|
||||
_LOGGER.error("Station %s is not available", station_id)
|
||||
return
|
||||
|
||||
station_name = osm_api.api.data['name'] if name is None else name
|
||||
|
||||
async_add_entities([OpenSenseMapQuality(station_name, osm_api)], True)
|
||||
|
||||
|
||||
class OpenSenseMapQuality(AirQualityEntity):
|
||||
"""Implementation of an openSenseMap air quality entity."""
|
||||
|
||||
def __init__(self, name, osm):
|
||||
"""Initialize the air quality entity."""
|
||||
self._name = name
|
||||
self._osm = osm
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the air quality entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def particulate_matter_2_5(self):
|
||||
"""Return the particulate matter 2.5 level."""
|
||||
return self._osm.api.pm2_5
|
||||
|
||||
@property
|
||||
def particulate_matter_10(self):
|
||||
"""Return the particulate matter 10 level."""
|
||||
return self._osm.api.pm10
|
||||
|
||||
@property
|
||||
def attribution(self):
|
||||
"""Return the attribution."""
|
||||
return ATTRIBUTION
|
||||
|
||||
async def async_update(self):
|
||||
"""Get the latest data from the openSenseMap API."""
|
||||
await self._osm.async_update()
|
||||
|
||||
|
||||
class OpenSenseMapData:
|
||||
"""Get the latest data and update the states."""
|
||||
|
||||
def __init__(self, api):
|
||||
"""Initialize the data object."""
|
||||
self.api = api
|
||||
|
||||
@Throttle(SCAN_INTERVAL)
|
||||
async def async_update(self):
|
||||
"""Get the latest data from the Pi-hole."""
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
|
||||
try:
|
||||
await self.api.get_data()
|
||||
except OpenSenseMapError as err:
|
||||
_LOGGER.error("Unable to fetch data: %s", err)
|
||||
@@ -25,7 +25,7 @@ ATTR_CHANGED_BY = 'changed_by'
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
ALARM_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Optional(ATTR_CODE): cv.string,
|
||||
})
|
||||
|
||||
|
||||
@@ -25,21 +25,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
return
|
||||
data = hass.data[BLINK_DATA]
|
||||
|
||||
# Current version of blinkpy API only supports one sync module. When
|
||||
# support for additional models is added, the sync module name should
|
||||
# come from the API.
|
||||
sync_modules = []
|
||||
sync_modules.append(BlinkSyncModule(data, 'sync'))
|
||||
for sync_name, sync_module in data.sync.items():
|
||||
sync_modules.append(BlinkSyncModule(data, sync_name, sync_module))
|
||||
add_entities(sync_modules, True)
|
||||
|
||||
|
||||
class BlinkSyncModule(AlarmControlPanel):
|
||||
"""Representation of a Blink Alarm Control Panel."""
|
||||
|
||||
def __init__(self, data, name):
|
||||
def __init__(self, data, name, sync):
|
||||
"""Initialize the alarm control panel."""
|
||||
self.data = data
|
||||
self.sync = data.sync
|
||||
self.sync = sync
|
||||
self._name = name
|
||||
self._state = None
|
||||
|
||||
@@ -68,6 +66,7 @@ class BlinkSyncModule(AlarmControlPanel):
|
||||
"""Return the state attributes."""
|
||||
attr = self.sync.attributes
|
||||
attr['network_info'] = self.data.networks
|
||||
attr['associated_cameras'] = list(self.sync.cameras.keys())
|
||||
attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION
|
||||
return attr
|
||||
|
||||
|
||||
@@ -5,14 +5,16 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.ialarm/
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
|
||||
CONF_CODE, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_TRIGGERED)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyialarm==0.3']
|
||||
@@ -36,6 +38,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): vol.All(cv.string, no_application_protocol),
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_CODE): cv.positive_int,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
})
|
||||
|
||||
@@ -43,23 +46,25 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up an iAlarm control panel."""
|
||||
name = config.get(CONF_NAME)
|
||||
code = config.get(CONF_CODE)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
host = config.get(CONF_HOST)
|
||||
|
||||
url = 'http://{}'.format(host)
|
||||
ialarm = IAlarmPanel(name, username, password, url)
|
||||
ialarm = IAlarmPanel(name, code, username, password, url)
|
||||
add_entities([ialarm], True)
|
||||
|
||||
|
||||
class IAlarmPanel(alarm.AlarmControlPanel):
|
||||
"""Representation of an iAlarm status."""
|
||||
|
||||
def __init__(self, name, username, password, url):
|
||||
def __init__(self, name, code, username, password, url):
|
||||
"""Initialize the iAlarm status."""
|
||||
from pyialarm import IAlarm
|
||||
|
||||
self._name = name
|
||||
self._code = str(code) if code else None
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._url = url
|
||||
@@ -71,6 +76,15 @@ class IAlarmPanel(alarm.AlarmControlPanel):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
if isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
@@ -98,12 +112,22 @@ class IAlarmPanel(alarm.AlarmControlPanel):
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
self._client.disarm()
|
||||
if self._validate_code(code):
|
||||
self._client.disarm()
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
self._client.arm_away()
|
||||
if self._validate_code(code):
|
||||
self._client.arm_away()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
self._client.arm_stay()
|
||||
if self._validate_code(code):
|
||||
self._client.arm_stay()
|
||||
|
||||
def _validate_code(self, code):
|
||||
"""Validate given code."""
|
||||
check = self._code is None or code == self._code
|
||||
if not check:
|
||||
_LOGGER.warning("Wrong code entered")
|
||||
return check
|
||||
|
||||
@@ -12,7 +12,8 @@ from homeassistant.components.lupusec import DOMAIN as LUPUSEC_DOMAIN
|
||||
from homeassistant.components.lupusec import LupusecDevice
|
||||
from homeassistant.const import (STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_DISARMED)
|
||||
STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_TRIGGERED)
|
||||
|
||||
DEPENDENCIES = ['lupusec']
|
||||
|
||||
@@ -50,6 +51,8 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanel):
|
||||
state = STATE_ALARM_ARMED_AWAY
|
||||
elif self._device.is_home:
|
||||
state = STATE_ALARM_ARMED_HOME
|
||||
elif self._device.is_alarm_triggered:
|
||||
state = STATE_ALARM_TRIGGERED
|
||||
else:
|
||||
state = None
|
||||
return state
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.const import (
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import track_point_in_time
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers.restore_state import async_get_last_state
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -116,7 +116,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
)])
|
||||
|
||||
|
||||
class ManualAlarm(alarm.AlarmControlPanel):
|
||||
class ManualAlarm(alarm.AlarmControlPanel, RestoreEntity):
|
||||
"""
|
||||
Representation of an alarm status.
|
||||
|
||||
@@ -310,7 +310,15 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added to hass."""
|
||||
state = await async_get_last_state(self.hass, self.entity_id)
|
||||
await super().async_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
self._state = state.state
|
||||
self._state_ts = state.last_updated
|
||||
if state.state == STATE_ALARM_PENDING and \
|
||||
hasattr(state, 'attributes') and \
|
||||
state.attributes['pre_pending_state']:
|
||||
# If in pending state, we return to the pre_pending_state
|
||||
self._state = state.attributes['pre_pending_state']
|
||||
self._state_ts = dt_util.utcnow()
|
||||
else:
|
||||
self._state = state.state
|
||||
self._state_ts = state.last_updated
|
||||
|
||||
@@ -13,13 +13,14 @@ from homeassistant.core import callback
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN,
|
||||
CONF_NAME, CONF_CODE)
|
||||
CONF_CODE, CONF_DEVICE, CONF_NAME, STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_PENDING,
|
||||
STATE_ALARM_TRIGGERED, STATE_UNKNOWN)
|
||||
from homeassistant.components.mqtt import (
|
||||
ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC,
|
||||
CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate)
|
||||
ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC,
|
||||
CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN,
|
||||
CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate,
|
||||
MqttEntityDeviceInfo, subscription)
|
||||
from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -30,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
CONF_PAYLOAD_DISARM = 'payload_disarm'
|
||||
CONF_PAYLOAD_ARM_HOME = 'payload_arm_home'
|
||||
CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away'
|
||||
CONF_UNIQUE_ID = 'unique_id'
|
||||
|
||||
DEFAULT_ARM_AWAY = 'ARM_AWAY'
|
||||
DEFAULT_ARM_HOME = 'ARM_HOME'
|
||||
@@ -45,13 +47,15 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
|
||||
}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema)
|
||||
|
||||
|
||||
async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
async_add_entities, discovery_info=None):
|
||||
"""Set up MQTT alarm control panel through configuration.yaml."""
|
||||
await _async_setup_entity(hass, config, async_add_entities)
|
||||
await _async_setup_entity(config, async_add_entities)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
@@ -59,7 +63,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
async def async_discover(discovery_payload):
|
||||
"""Discover and add an MQTT alarm control panel."""
|
||||
config = PLATFORM_SCHEMA(discovery_payload)
|
||||
await _async_setup_entity(hass, config, async_add_entities,
|
||||
await _async_setup_entity(config, async_add_entities,
|
||||
discovery_payload[ATTR_DISCOVERY_HASH])
|
||||
|
||||
async_dispatcher_connect(
|
||||
@@ -67,54 +71,50 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
async_discover)
|
||||
|
||||
|
||||
async def _async_setup_entity(hass, config, async_add_entities,
|
||||
async def _async_setup_entity(config, async_add_entities,
|
||||
discovery_hash=None):
|
||||
"""Set up the MQTT Alarm Control Panel platform."""
|
||||
async_add_entities([MqttAlarm(
|
||||
config.get(CONF_NAME),
|
||||
config.get(CONF_STATE_TOPIC),
|
||||
config.get(CONF_COMMAND_TOPIC),
|
||||
config.get(CONF_QOS),
|
||||
config.get(CONF_RETAIN),
|
||||
config.get(CONF_PAYLOAD_DISARM),
|
||||
config.get(CONF_PAYLOAD_ARM_HOME),
|
||||
config.get(CONF_PAYLOAD_ARM_AWAY),
|
||||
config.get(CONF_CODE),
|
||||
config.get(CONF_AVAILABILITY_TOPIC),
|
||||
config.get(CONF_PAYLOAD_AVAILABLE),
|
||||
config.get(CONF_PAYLOAD_NOT_AVAILABLE),
|
||||
discovery_hash,)])
|
||||
async_add_entities([MqttAlarm(config, discovery_hash)])
|
||||
|
||||
|
||||
class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate,
|
||||
class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo,
|
||||
alarm.AlarmControlPanel):
|
||||
"""Representation of a MQTT alarm status."""
|
||||
|
||||
def __init__(self, name, state_topic, command_topic, qos, retain,
|
||||
payload_disarm, payload_arm_home, payload_arm_away, code,
|
||||
availability_topic, payload_available, payload_not_available,
|
||||
discovery_hash):
|
||||
def __init__(self, config, discovery_hash):
|
||||
"""Init the MQTT Alarm Control Panel."""
|
||||
self._state = STATE_UNKNOWN
|
||||
self._config = config
|
||||
self._unique_id = config.get(CONF_UNIQUE_ID)
|
||||
self._sub_state = None
|
||||
|
||||
availability_topic = config.get(CONF_AVAILABILITY_TOPIC)
|
||||
payload_available = config.get(CONF_PAYLOAD_AVAILABLE)
|
||||
payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE)
|
||||
qos = config.get(CONF_QOS)
|
||||
device_config = config.get(CONF_DEVICE)
|
||||
|
||||
MqttAvailability.__init__(self, availability_topic, qos,
|
||||
payload_available, payload_not_available)
|
||||
MqttDiscoveryUpdate.__init__(self, discovery_hash)
|
||||
self._state = STATE_UNKNOWN
|
||||
self._name = name
|
||||
self._state_topic = state_topic
|
||||
self._command_topic = command_topic
|
||||
self._qos = qos
|
||||
self._retain = retain
|
||||
self._payload_disarm = payload_disarm
|
||||
self._payload_arm_home = payload_arm_home
|
||||
self._payload_arm_away = payload_arm_away
|
||||
self._code = code
|
||||
self._discovery_hash = discovery_hash
|
||||
MqttDiscoveryUpdate.__init__(self, discovery_hash,
|
||||
self.discovery_update)
|
||||
MqttEntityDeviceInfo.__init__(self, device_config)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe mqtt events."""
|
||||
await MqttAvailability.async_added_to_hass(self)
|
||||
await MqttDiscoveryUpdate.async_added_to_hass(self)
|
||||
await super().async_added_to_hass()
|
||||
await self._subscribe_topics()
|
||||
|
||||
async def discovery_update(self, discovery_payload):
|
||||
"""Handle updated discovery message."""
|
||||
config = PLATFORM_SCHEMA(discovery_payload)
|
||||
self._config = config
|
||||
await self.availability_discovery_update(config)
|
||||
await self._subscribe_topics()
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def _subscribe_topics(self):
|
||||
"""(Re)Subscribe to topics."""
|
||||
@callback
|
||||
def message_received(topic, payload, qos):
|
||||
"""Run when new MQTT message has been received."""
|
||||
@@ -126,8 +126,17 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate,
|
||||
self._state = payload
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._state_topic, message_received, self._qos)
|
||||
self._sub_state = await subscription.async_subscribe_topics(
|
||||
self.hass, self._sub_state,
|
||||
{'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC),
|
||||
'msg_callback': message_received,
|
||||
'qos': self._config.get(CONF_QOS)}})
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Unsubscribe when removed."""
|
||||
self._sub_state = await subscription.async_unsubscribe_topics(
|
||||
self.hass, self._sub_state)
|
||||
await MqttAvailability.async_will_remove_from_hass(self)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -137,7 +146,12 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate,
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
return self._config.get(CONF_NAME)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
@@ -147,9 +161,10 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate,
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
code = self._config.get(CONF_CODE)
|
||||
if code is None:
|
||||
return None
|
||||
if isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
if isinstance(code, str) and re.search('^\\d+$', code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
@@ -161,8 +176,10 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate,
|
||||
if not self._validate_code(code, 'disarming'):
|
||||
return
|
||||
mqtt.async_publish(
|
||||
self.hass, self._command_topic, self._payload_disarm, self._qos,
|
||||
self._retain)
|
||||
self.hass, self._config.get(CONF_COMMAND_TOPIC),
|
||||
self._config.get(CONF_PAYLOAD_DISARM),
|
||||
self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
async def async_alarm_arm_home(self, code=None):
|
||||
"""Send arm home command.
|
||||
@@ -172,8 +189,10 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate,
|
||||
if not self._validate_code(code, 'arming home'):
|
||||
return
|
||||
mqtt.async_publish(
|
||||
self.hass, self._command_topic, self._payload_arm_home, self._qos,
|
||||
self._retain)
|
||||
self.hass, self._config.get(CONF_COMMAND_TOPIC),
|
||||
self._config.get(CONF_PAYLOAD_ARM_HOME),
|
||||
self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
async def async_alarm_arm_away(self, code=None):
|
||||
"""Send arm away command.
|
||||
@@ -183,12 +202,15 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate,
|
||||
if not self._validate_code(code, 'arming away'):
|
||||
return
|
||||
mqtt.async_publish(
|
||||
self.hass, self._command_topic, self._payload_arm_away, self._qos,
|
||||
self._retain)
|
||||
self.hass, self._config.get(CONF_COMMAND_TOPIC),
|
||||
self._config.get(CONF_PAYLOAD_ARM_AWAY),
|
||||
self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
def _validate_code(self, code, state):
|
||||
"""Validate given code."""
|
||||
check = self._code is None or code == self._code
|
||||
conf_code = self._config.get(CONF_CODE)
|
||||
check = conf_code is None or code == conf_code
|
||||
if not check:
|
||||
_LOGGER.warning('Wrong code entered for %s', state)
|
||||
return check
|
||||
|
||||
107
homeassistant/components/alarm_control_panel/ness_alarm.py
Normal file
107
homeassistant/components/alarm_control_panel/ness_alarm.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Support for Ness D8X/D16X alarm panel.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.ness_alarm/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components.ness_alarm import (
|
||||
DATA_NESS, SIGNAL_ARMING_STATE_CHANGED)
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMING,
|
||||
STATE_ALARM_TRIGGERED, STATE_ALARM_PENDING, STATE_ALARM_DISARMED)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['ness_alarm']
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up the Ness Alarm alarm control panel devices."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
device = NessAlarmPanel(hass.data[DATA_NESS], 'Alarm Panel')
|
||||
async_add_entities([device])
|
||||
|
||||
|
||||
class NessAlarmPanel(alarm.AlarmControlPanel):
|
||||
"""Representation of a Ness alarm panel."""
|
||||
|
||||
def __init__(self, client, name):
|
||||
"""Initialize the alarm panel."""
|
||||
self._client = client
|
||||
self._name = name
|
||||
self._state = None
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_ARMING_STATE_CHANGED,
|
||||
self._handle_arming_state_change)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the regex for code format or None if no code is required."""
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._state
|
||||
|
||||
async def async_alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
await self._client.disarm(code)
|
||||
|
||||
async def async_alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
await self._client.arm_away(code)
|
||||
|
||||
async def async_alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
await self._client.arm_home(code)
|
||||
|
||||
async def async_alarm_trigger(self, code=None):
|
||||
"""Send trigger/panic command."""
|
||||
await self._client.panic(code)
|
||||
|
||||
@callback
|
||||
def _handle_arming_state_change(self, arming_state):
|
||||
"""Handle arming state update."""
|
||||
from nessclient import ArmingState
|
||||
|
||||
if arming_state == ArmingState.UNKNOWN:
|
||||
self._state = None
|
||||
elif arming_state == ArmingState.DISARMED:
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
elif arming_state == ArmingState.ARMING:
|
||||
self._state = STATE_ALARM_ARMING
|
||||
elif arming_state == ArmingState.EXIT_DELAY:
|
||||
self._state = STATE_ALARM_ARMING
|
||||
elif arming_state == ArmingState.ARMED:
|
||||
self._state = STATE_ALARM_ARMED_AWAY
|
||||
elif arming_state == ArmingState.ENTRY_DELAY:
|
||||
self._state = STATE_ALARM_PENDING
|
||||
elif arming_state == ArmingState.TRIGGERED:
|
||||
self._state = STATE_ALARM_TRIGGERED
|
||||
else:
|
||||
_LOGGER.warning("Unhandled arming state: %s", arming_state)
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
@@ -13,7 +13,7 @@ import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_NAME, CONF_PORT, STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN)
|
||||
STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pynx584==0.4']
|
||||
@@ -43,7 +43,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
add_entities([NX584Alarm(hass, url, name)])
|
||||
except requests.exceptions.ConnectionError as ex:
|
||||
_LOGGER.error("Unable to connect to NX584: %s", str(ex))
|
||||
return False
|
||||
return
|
||||
|
||||
|
||||
class NX584Alarm(alarm.AlarmControlPanel):
|
||||
@@ -60,7 +60,7 @@ class NX584Alarm(alarm.AlarmControlPanel):
|
||||
# talk to the API and trigger a requests exception for setup_platform()
|
||||
# to catch
|
||||
self._alarm.list_zones()
|
||||
self._state = STATE_UNKNOWN
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -85,11 +85,11 @@ class NX584Alarm(alarm.AlarmControlPanel):
|
||||
except requests.exceptions.ConnectionError as ex:
|
||||
_LOGGER.error("Unable to connect to %(host)s: %(reason)s",
|
||||
dict(host=self._url, reason=ex))
|
||||
self._state = STATE_UNKNOWN
|
||||
self._state = None
|
||||
zones = []
|
||||
except IndexError:
|
||||
_LOGGER.error("NX584 reports no partitions")
|
||||
self._state = STATE_UNKNOWN
|
||||
self._state = None
|
||||
zones = []
|
||||
|
||||
bypassed = False
|
||||
@@ -107,6 +107,10 @@ class NX584Alarm(alarm.AlarmControlPanel):
|
||||
else:
|
||||
self._state = STATE_ALARM_ARMED_AWAY
|
||||
|
||||
for flag in part['condition_flags']:
|
||||
if flag == "Siren on":
|
||||
self._state = STATE_ALARM_TRIGGERED
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
self._alarm.disarm(code)
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['yalesmartalarmclient==0.1.4']
|
||||
REQUIREMENTS = ['yalesmartalarmclient==0.1.6']
|
||||
|
||||
CONF_AREA_ID = 'area_id'
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ CONF_DEVICE_TYPE = 'type'
|
||||
CONF_PANEL_DISPLAY = 'panel_display'
|
||||
CONF_ZONE_NAME = 'name'
|
||||
CONF_ZONE_TYPE = 'type'
|
||||
CONF_ZONE_LOOP = 'loop'
|
||||
CONF_ZONE_RFID = 'rfid'
|
||||
CONF_ZONES = 'zones'
|
||||
CONF_RELAY_ADDR = 'relayaddr'
|
||||
@@ -75,6 +76,8 @@ ZONE_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_ZONE_TYPE,
|
||||
default=DEFAULT_ZONE_TYPE): vol.Any(DEVICE_CLASSES_SCHEMA),
|
||||
vol.Optional(CONF_ZONE_RFID): cv.string,
|
||||
vol.Optional(CONF_ZONE_LOOP):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
|
||||
vol.Inclusive(CONF_RELAY_ADDR, 'relaylocation',
|
||||
'Relay address and channel must exist together'): cv.byte,
|
||||
vol.Inclusive(CONF_RELAY_CHAN, 'relaylocation',
|
||||
|
||||
@@ -13,8 +13,9 @@ from homeassistant.helpers import entityfilter
|
||||
|
||||
from . import flash_briefings, intent, smart_home
|
||||
from .const import (
|
||||
CONF_AUDIO, CONF_DISPLAY_URL, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN,
|
||||
CONF_FILTER, CONF_ENTITY_CONFIG)
|
||||
CONF_AUDIO, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY_URL,
|
||||
CONF_ENDPOINT, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, CONF_FILTER,
|
||||
CONF_ENTITY_CONFIG)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,6 +31,9 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
SMART_HOME_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_ENDPOINT): cv.string,
|
||||
vol.Optional(CONF_CLIENT_ID): cv.string,
|
||||
vol.Optional(CONF_CLIENT_SECRET): cv.string,
|
||||
vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA}
|
||||
})
|
||||
|
||||
154
homeassistant/components/alexa/auth.py
Normal file
154
homeassistant/components/alexa/auth.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Support for Alexa skill auth."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.util import dt
|
||||
from .const import DEFAULT_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token"
|
||||
LWA_HEADERS = {
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
|
||||
}
|
||||
|
||||
PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300
|
||||
STORAGE_KEY = 'alexa_auth'
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_EXPIRE_TIME = "expire_time"
|
||||
STORAGE_ACCESS_TOKEN = "access_token"
|
||||
STORAGE_REFRESH_TOKEN = "refresh_token"
|
||||
|
||||
|
||||
class Auth:
|
||||
"""Handle authentication to send events to Alexa."""
|
||||
|
||||
def __init__(self, hass, client_id, client_secret):
|
||||
"""Initialize the Auth class."""
|
||||
self.hass = hass
|
||||
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
|
||||
self._prefs = None
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
|
||||
self._get_token_lock = asyncio.Lock(loop=hass.loop)
|
||||
|
||||
async def async_do_auth(self, accept_grant_code):
|
||||
"""Do authentication with an AcceptGrant code."""
|
||||
# access token not retrieved yet for the first time, so this should
|
||||
# be an access token request
|
||||
|
||||
lwa_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": accept_grant_code,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret
|
||||
}
|
||||
_LOGGER.debug("Calling LWA to get the access token (first time), "
|
||||
"with: %s", json.dumps(lwa_params))
|
||||
|
||||
return await self._async_request_new_token(lwa_params)
|
||||
|
||||
async def async_get_access_token(self):
|
||||
"""Perform access token or token refresh request."""
|
||||
async with self._get_token_lock:
|
||||
if self._prefs is None:
|
||||
await self.async_load_preferences()
|
||||
|
||||
if self.is_token_valid():
|
||||
_LOGGER.debug("Token still valid, using it.")
|
||||
return self._prefs[STORAGE_ACCESS_TOKEN]
|
||||
|
||||
if self._prefs[STORAGE_REFRESH_TOKEN] is None:
|
||||
_LOGGER.debug("Token invalid and no refresh token available.")
|
||||
return None
|
||||
|
||||
lwa_params = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self._prefs[STORAGE_REFRESH_TOKEN],
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret
|
||||
}
|
||||
|
||||
_LOGGER.debug("Calling LWA to refresh the access token.")
|
||||
return await self._async_request_new_token(lwa_params)
|
||||
|
||||
@callback
|
||||
def is_token_valid(self):
|
||||
"""Check if a token is already loaded and if it is still valid."""
|
||||
if not self._prefs[STORAGE_ACCESS_TOKEN]:
|
||||
return False
|
||||
|
||||
expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME])
|
||||
preemptive_expire_time = expire_time - timedelta(
|
||||
seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS)
|
||||
|
||||
return dt.utcnow() < preemptive_expire_time
|
||||
|
||||
async def _async_request_new_token(self, lwa_params):
|
||||
|
||||
try:
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
with async_timeout.timeout(DEFAULT_TIMEOUT, loop=self.hass.loop):
|
||||
response = await session.post(LWA_TOKEN_URI,
|
||||
headers=LWA_HEADERS,
|
||||
data=lwa_params,
|
||||
allow_redirects=True)
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
||||
_LOGGER.error("Timeout calling LWA to get auth token.")
|
||||
return None
|
||||
|
||||
_LOGGER.debug("LWA response header: %s", response.headers)
|
||||
_LOGGER.debug("LWA response status: %s", response.status)
|
||||
|
||||
if response.status != 200:
|
||||
_LOGGER.error("Error calling LWA to get auth token.")
|
||||
return None
|
||||
|
||||
response_json = await response.json()
|
||||
_LOGGER.debug("LWA response body : %s", response_json)
|
||||
|
||||
access_token = response_json["access_token"]
|
||||
refresh_token = response_json["refresh_token"]
|
||||
expires_in = response_json["expires_in"]
|
||||
expire_time = dt.utcnow() + timedelta(seconds=expires_in)
|
||||
|
||||
await self._async_update_preferences(access_token, refresh_token,
|
||||
expire_time.isoformat())
|
||||
|
||||
return access_token
|
||||
|
||||
async def async_load_preferences(self):
|
||||
"""Load preferences with stored tokens."""
|
||||
self._prefs = await self._store.async_load()
|
||||
|
||||
if self._prefs is None:
|
||||
self._prefs = {
|
||||
STORAGE_ACCESS_TOKEN: None,
|
||||
STORAGE_REFRESH_TOKEN: None,
|
||||
STORAGE_EXPIRE_TIME: None
|
||||
}
|
||||
|
||||
async def _async_update_preferences(self, access_token, refresh_token,
|
||||
expire_time):
|
||||
"""Update user preferences."""
|
||||
if self._prefs is None:
|
||||
await self.async_load_preferences()
|
||||
|
||||
if access_token is not None:
|
||||
self._prefs[STORAGE_ACCESS_TOKEN] = access_token
|
||||
if refresh_token is not None:
|
||||
self._prefs[STORAGE_REFRESH_TOKEN] = refresh_token
|
||||
if expire_time is not None:
|
||||
self._prefs[STORAGE_EXPIRE_TIME] = expire_time
|
||||
await self._store.async_save(self._prefs)
|
||||
@@ -10,6 +10,9 @@ CONF_DISPLAY_URL = 'display_url'
|
||||
|
||||
CONF_FILTER = 'filter'
|
||||
CONF_ENTITY_CONFIG = 'entity_config'
|
||||
CONF_ENDPOINT = 'endpoint'
|
||||
CONF_CLIENT_ID = 'client_id'
|
||||
CONF_CLIENT_SECRET = 'client_secret'
|
||||
|
||||
ATTR_UID = 'uid'
|
||||
ATTR_UPDATE_DATE = 'updateDate'
|
||||
@@ -21,3 +24,5 @@ ATTR_REDIRECTION_URL = 'redirectionURL'
|
||||
SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH'
|
||||
|
||||
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z'
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
@@ -5,15 +5,22 @@ https://developer.amazon.com/docs/smarthome/understand-the-smart-home-skill-api.
|
||||
https://developer.amazon.com/docs/device-apis/message-guide.html
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from uuid import uuid4
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.components import (
|
||||
alert, automation, binary_sensor, climate, cover, fan, group, http,
|
||||
input_boolean, light, lock, media_player, scene, script, sensor, switch)
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
@@ -21,13 +28,15 @@ from homeassistant.const import (
|
||||
SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
SERVICE_UNLOCK, SERVICE_VOLUME_SET, STATE_LOCKED, STATE_ON, STATE_UNLOCKED,
|
||||
TEMP_CELSIUS, TEMP_FAHRENHEIT)
|
||||
TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL)
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.util.temperature import convert as convert_temperature
|
||||
|
||||
from .const import CONF_ENTITY_CONFIG, CONF_FILTER
|
||||
from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \
|
||||
CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT
|
||||
from .auth import Auth
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +46,8 @@ API_EVENT = 'event'
|
||||
API_CONTEXT = 'context'
|
||||
API_HEADER = 'header'
|
||||
API_PAYLOAD = 'payload'
|
||||
API_SCOPE = 'scope'
|
||||
API_CHANGE = 'change'
|
||||
|
||||
API_TEMP_UNITS = {
|
||||
TEMP_FAHRENHEIT: 'FAHRENHEIT',
|
||||
@@ -66,6 +77,8 @@ HANDLERS = Registry()
|
||||
ENTITY_ADAPTERS = Registry()
|
||||
EVENT_ALEXA_SMART_HOME = 'alexa_smart_home'
|
||||
|
||||
AUTH_KEY = "alexa.smart_home.auth"
|
||||
|
||||
|
||||
class _DisplayCategory:
|
||||
"""Possible display categories for Discovery response.
|
||||
@@ -375,6 +388,8 @@ class _AlexaInterface:
|
||||
'name': prop_name,
|
||||
'namespace': self.name(),
|
||||
'value': prop_value,
|
||||
'timeOfSample': datetime.now().strftime(DATE_FORMAT),
|
||||
'uncertaintyInMilliseconds': 0
|
||||
}
|
||||
|
||||
|
||||
@@ -390,6 +405,9 @@ class _AlexaPowerController(_AlexaInterface):
|
||||
def properties_supported(self):
|
||||
return [{'name': 'powerState'}]
|
||||
|
||||
def properties_proactively_reported(self):
|
||||
return True
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
@@ -417,6 +435,9 @@ class _AlexaLockController(_AlexaInterface):
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
def properties_proactively_reported(self):
|
||||
return True
|
||||
|
||||
def get_property(self, name):
|
||||
if name != 'lockState':
|
||||
raise _UnsupportedProperty(name)
|
||||
@@ -454,6 +475,9 @@ class _AlexaBrightnessController(_AlexaInterface):
|
||||
def properties_supported(self):
|
||||
return [{'name': 'brightness'}]
|
||||
|
||||
def properties_proactively_reported(self):
|
||||
return True
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
@@ -504,6 +528,20 @@ class _AlexaColorTemperatureController(_AlexaInterface):
|
||||
def name(self):
|
||||
return 'Alexa.ColorTemperatureController'
|
||||
|
||||
def properties_supported(self):
|
||||
return [{'name': 'colorTemperatureInKelvin'}]
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
def get_property(self, name):
|
||||
if name != 'colorTemperatureInKelvin':
|
||||
raise _UnsupportedProperty(name)
|
||||
if 'color_temp' in self.entity.attributes:
|
||||
return color_util.color_temperature_mired_to_kelvin(
|
||||
self.entity.attributes['color_temp'])
|
||||
return 0
|
||||
|
||||
|
||||
class _AlexaPercentageController(_AlexaInterface):
|
||||
"""Implements Alexa.PercentageController.
|
||||
@@ -571,6 +609,9 @@ class _AlexaTemperatureSensor(_AlexaInterface):
|
||||
def properties_supported(self):
|
||||
return [{'name': 'temperature'}]
|
||||
|
||||
def properties_proactively_reported(self):
|
||||
return True
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
@@ -611,6 +652,9 @@ class _AlexaContactSensor(_AlexaInterface):
|
||||
def properties_supported(self):
|
||||
return [{'name': 'detectionState'}]
|
||||
|
||||
def properties_proactively_reported(self):
|
||||
return True
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
@@ -634,6 +678,9 @@ class _AlexaMotionSensor(_AlexaInterface):
|
||||
def properties_supported(self):
|
||||
return [{'name': 'detectionState'}]
|
||||
|
||||
def properties_proactively_reported(self):
|
||||
return True
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
@@ -672,6 +719,9 @@ class _AlexaThermostatController(_AlexaInterface):
|
||||
properties.append({'name': 'thermostatMode'})
|
||||
return properties
|
||||
|
||||
def properties_proactively_reported(self):
|
||||
return True
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
@@ -934,8 +984,11 @@ class _Cause:
|
||||
class Config:
|
||||
"""Hold the configuration for Alexa."""
|
||||
|
||||
def __init__(self, should_expose, entity_config=None):
|
||||
def __init__(self, endpoint, async_get_access_token, should_expose,
|
||||
entity_config=None):
|
||||
"""Initialize the configuration."""
|
||||
self.endpoint = endpoint
|
||||
self.async_get_access_token = async_get_access_token
|
||||
self.should_expose = should_expose
|
||||
self.entity_config = entity_config or {}
|
||||
|
||||
@@ -950,12 +1003,62 @@ def async_setup(hass, config):
|
||||
Even if that's disabled, the functionality in this module may still be used
|
||||
by the cloud component which will call async_handle_message directly.
|
||||
"""
|
||||
if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET):
|
||||
hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID],
|
||||
config[CONF_CLIENT_SECRET])
|
||||
|
||||
async_get_access_token = \
|
||||
hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \
|
||||
else None
|
||||
|
||||
smart_home_config = Config(
|
||||
endpoint=config.get(CONF_ENDPOINT),
|
||||
async_get_access_token=async_get_access_token,
|
||||
should_expose=config[CONF_FILTER],
|
||||
entity_config=config.get(CONF_ENTITY_CONFIG),
|
||||
)
|
||||
hass.http.register_view(SmartHomeView(smart_home_config))
|
||||
|
||||
if AUTH_KEY in hass.data:
|
||||
hass.loop.create_task(
|
||||
async_enable_proactive_mode(hass, smart_home_config))
|
||||
|
||||
|
||||
async def async_enable_proactive_mode(hass, smart_home_config):
|
||||
"""Enable the proactive mode.
|
||||
|
||||
Proactive mode makes this component report state changes to Alexa.
|
||||
"""
|
||||
if smart_home_config.async_get_access_token is None:
|
||||
# no function to call to get token
|
||||
return
|
||||
|
||||
if await smart_home_config.async_get_access_token() is None:
|
||||
# not ready yet
|
||||
return
|
||||
|
||||
async def async_entity_state_listener(changed_entity, old_state,
|
||||
new_state):
|
||||
if not smart_home_config.should_expose(changed_entity):
|
||||
_LOGGER.debug("Not exposing %s because filtered by config",
|
||||
changed_entity)
|
||||
return
|
||||
|
||||
if new_state.domain not in ENTITY_ADAPTERS:
|
||||
return
|
||||
|
||||
alexa_changed_entity = \
|
||||
ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config,
|
||||
new_state)
|
||||
|
||||
for interface in alexa_changed_entity.interfaces():
|
||||
if interface.properties_proactively_reported():
|
||||
await async_send_changereport_message(hass, smart_home_config,
|
||||
alexa_changed_entity)
|
||||
return
|
||||
|
||||
async_track_state_change(hass, MATCH_ALL, async_entity_state_listener)
|
||||
|
||||
|
||||
class SmartHomeView(http.HomeAssistantView):
|
||||
"""Expose Smart Home v3 payload interface via HTTP POST."""
|
||||
@@ -1098,6 +1201,24 @@ class _AlexaResponse:
|
||||
"""
|
||||
self._response[API_EVENT][API_HEADER]['correlationToken'] = token
|
||||
|
||||
def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None):
|
||||
"""Set the endpoint dictionary.
|
||||
|
||||
This is used to send proactive messages to Alexa.
|
||||
"""
|
||||
self._response[API_EVENT][API_ENDPOINT] = {
|
||||
API_SCOPE: {
|
||||
'type': 'BearerToken',
|
||||
'token': bearer_token
|
||||
}
|
||||
}
|
||||
|
||||
if endpoint_id is not None:
|
||||
self._response[API_EVENT][API_ENDPOINT]['endpointId'] = endpoint_id
|
||||
|
||||
if cookie is not None:
|
||||
self._response[API_EVENT][API_ENDPOINT]['cookie'] = cookie
|
||||
|
||||
def set_endpoint(self, endpoint):
|
||||
"""Set the endpoint.
|
||||
|
||||
@@ -1208,6 +1329,62 @@ async def async_handle_message(
|
||||
return response.serialize()
|
||||
|
||||
|
||||
async def async_send_changereport_message(hass, config, alexa_entity):
|
||||
"""Send a ChangeReport message for an Alexa entity."""
|
||||
token = await config.async_get_access_token()
|
||||
if not token:
|
||||
_LOGGER.error("Invalid access token.")
|
||||
return
|
||||
|
||||
headers = {
|
||||
"Authorization": "Bearer {}".format(token),
|
||||
"Content-Type": "application/json;charset=UTF-8"
|
||||
}
|
||||
|
||||
endpoint = alexa_entity.entity_id()
|
||||
|
||||
# this sends all the properties of the Alexa Entity, whether they have
|
||||
# changed or not. this should be improved, and properties that have not
|
||||
# changed should be moved to the 'context' object
|
||||
properties = list(alexa_entity.serialize_properties())
|
||||
|
||||
payload = {
|
||||
API_CHANGE: {
|
||||
'cause': {'type': _Cause.APP_INTERACTION},
|
||||
'properties': properties
|
||||
}
|
||||
}
|
||||
|
||||
message = _AlexaResponse(name='ChangeReport', namespace='Alexa',
|
||||
payload=payload)
|
||||
message.set_endpoint_full(token, endpoint)
|
||||
|
||||
message_str = json.dumps(message.serialize())
|
||||
|
||||
try:
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop):
|
||||
response = await session.post(config.endpoint,
|
||||
headers=headers,
|
||||
data=message_str,
|
||||
allow_redirects=True)
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
||||
_LOGGER.error("Timeout calling LWA to get auth token.")
|
||||
return None
|
||||
|
||||
response_text = await response.text()
|
||||
|
||||
_LOGGER.debug("Sent: %s", message_str)
|
||||
_LOGGER.debug("Received (%s): %s", response.status, response_text)
|
||||
|
||||
if response.status != 202:
|
||||
response_json = json.loads(response_text)
|
||||
_LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s",
|
||||
response_json["payload"]["code"],
|
||||
response_json["payload"]["description"])
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Discovery', 'Discover'))
|
||||
async def async_api_discovery(hass, config, directive, context):
|
||||
"""Create a API formatted discovery response.
|
||||
@@ -1244,8 +1421,9 @@ async def async_api_discovery(hass, config, directive, context):
|
||||
i.serialize_discovery() for i in alexa_entity.interfaces()]
|
||||
|
||||
if not endpoint['capabilities']:
|
||||
_LOGGER.debug("Not exposing %s because it has no capabilities",
|
||||
entity.entity_id)
|
||||
_LOGGER.debug(
|
||||
"Not exposing %s because it has no capabilities",
|
||||
entity.entity_id)
|
||||
continue
|
||||
discovery_endpoints.append(endpoint)
|
||||
|
||||
@@ -1256,6 +1434,25 @@ async def async_api_discovery(hass, config, directive, context):
|
||||
)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Authorization', 'AcceptGrant'))
|
||||
async def async_api_accept_grant(hass, config, directive, context):
|
||||
"""Create a API formatted AcceptGrant response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
auth_code = directive.payload['grant']['code']
|
||||
_LOGGER.debug("AcceptGrant code: %s", auth_code)
|
||||
|
||||
if AUTH_KEY in hass.data:
|
||||
await hass.data[AUTH_KEY].async_do_auth(auth_code)
|
||||
await async_enable_proactive_mode(hass, config)
|
||||
|
||||
return directive.response(
|
||||
name='AcceptGrant.Response',
|
||||
namespace='Alexa.Authorization',
|
||||
payload={})
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PowerController', 'TurnOn'))
|
||||
async def async_api_turn_on(hass, config, directive, context):
|
||||
"""Process a turn on request."""
|
||||
|
||||
@@ -9,7 +9,9 @@ import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web_exceptions import HTTPBadRequest
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.bootstrap import DATA_LOGGING
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
@@ -21,7 +23,8 @@ from homeassistant.const import (
|
||||
URL_API_TEMPLATE, __version__)
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.auth.permissions.const import POLICY_READ
|
||||
from homeassistant.exceptions import TemplateError, Unauthorized
|
||||
from homeassistant.exceptions import (
|
||||
TemplateError, Unauthorized, ServiceNotFound)
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
from homeassistant.helpers.state import AsyncTrackStates
|
||||
@@ -339,8 +342,11 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
"Data should be valid JSON.", HTTP_BAD_REQUEST)
|
||||
|
||||
with AsyncTrackStates(hass) as changed_states:
|
||||
await hass.services.async_call(
|
||||
domain, service, data, True, self.context(request))
|
||||
try:
|
||||
await hass.services.async_call(
|
||||
domain, service, data, True, self.context(request))
|
||||
except (vol.Invalid, ServiceNotFound):
|
||||
raise HTTPBadRequest()
|
||||
|
||||
return self.json(changed_states)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyatv==0.3.10']
|
||||
REQUIREMENTS = ['pyatv==0.3.12']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers.event import track_time_interval
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
REQUIREMENTS = ['pyarlo==0.2.2']
|
||||
REQUIREMENTS = ['pyarlo==0.2.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
|
||||
REQUIREMENTS = ['aioasuswrt==1.1.11']
|
||||
REQUIREMENTS = ['aioasuswrt==1.1.17']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import voluptuous as vol
|
||||
from requests import RequestException
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (
|
||||
CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.helpers import discovery
|
||||
@@ -141,11 +140,11 @@ def setup(hass, config):
|
||||
from requests import Session
|
||||
|
||||
conf = config[DOMAIN]
|
||||
api_http_session = None
|
||||
try:
|
||||
api_http_session = Session()
|
||||
except RequestException as ex:
|
||||
_LOGGER.warning("Creating HTTP session failed with: %s", str(ex))
|
||||
api_http_session = None
|
||||
|
||||
api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session)
|
||||
|
||||
@@ -157,6 +156,20 @@ def setup(hass, config):
|
||||
install_id=conf.get(CONF_INSTALL_ID),
|
||||
access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE))
|
||||
|
||||
def close_http_session(event):
|
||||
"""Close API sessions used to connect to August."""
|
||||
_LOGGER.debug("Closing August HTTP sessions")
|
||||
if api_http_session:
|
||||
try:
|
||||
api_http_session.close()
|
||||
except RequestException:
|
||||
pass
|
||||
|
||||
_LOGGER.debug("August HTTP session closed.")
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session)
|
||||
_LOGGER.debug("Registered for HASS stop event")
|
||||
|
||||
return setup_august(hass, config, api, authenticator)
|
||||
|
||||
|
||||
@@ -178,22 +191,6 @@ class AugustData:
|
||||
self._door_state_by_id = {}
|
||||
self._activities_by_id = {}
|
||||
|
||||
@callback
|
||||
def august_api_stop(event):
|
||||
"""Close the API HTTP session."""
|
||||
_LOGGER.debug("Closing August HTTP session")
|
||||
|
||||
try:
|
||||
self._api.http_session.close()
|
||||
self._api.http_session = None
|
||||
except RequestException:
|
||||
pass
|
||||
_LOGGER.debug("August HTTP session closed.")
|
||||
|
||||
self._hass.bus.listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, august_api_stop)
|
||||
_LOGGER.debug("Registered for HASS stop event")
|
||||
|
||||
@property
|
||||
def house_ids(self):
|
||||
"""Return a list of house_ids."""
|
||||
|
||||
@@ -5,28 +5,28 @@
|
||||
"no_available_service": "No hi ha serveis de notificaci\u00f3 disponibles."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Codi inv\u00e0lid, si us plau torni a provar-ho."
|
||||
"invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Seleccioneu un dels serveis de notificaci\u00f3:",
|
||||
"title": "Configureu una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions"
|
||||
"description": "Selecciona un dels serveis de notificaci\u00f3:",
|
||||
"title": "Configuraci\u00f3 d'una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions"
|
||||
},
|
||||
"setup": {
|
||||
"description": "**notify.{notify_service}** ha enviat una contrasenya d'un sol \u00fas. Introdu\u00efu-la a continuaci\u00f3:",
|
||||
"title": "Verifiqueu la configuraci\u00f3"
|
||||
"description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdueix-la a continuaci\u00f3:",
|
||||
"title": "Verificaci\u00f3 de la configuraci\u00f3"
|
||||
}
|
||||
},
|
||||
"title": "Contrasenya d'un sol \u00fas del servei de notificacions"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Codi inv\u00e0lid, si us plau torni a provar-ho. Si obteniu aquest error repetidament, assegureu-vos que la data i hora de Home Assistant sigui correcta i precisa."
|
||||
"invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho. Si obtens aquest error repetidament, assegura't que la data i hora de Home Assistant siguin correctes i acurades."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escanegeu el codi QR amb la vostre aplicaci\u00f3 de verificaci\u00f3. Si no en teniu cap, us recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdu\u00efu el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si teniu problemes per escanejar el codi QR, feu una configuraci\u00f3 manual amb el codi **`{code}`**.",
|
||||
"title": "Configureu la verificaci\u00f3 en dos passos utilitzant TOTP"
|
||||
"description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escaneja el codi QR amb la teva aplicaci\u00f3 de verificaci\u00f3. Si no en tens cap, et recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdueix el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si tens problemes per escanejar el codi QR, fes una configuraci\u00f3 manual amb el codi **`{code}`**.",
|
||||
"title": "Configura la verificaci\u00f3 en dos passos utilitzant TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"title": "Nastavte jednor\u00e1zov\u00e9 heslo dodan\u00e9 komponentou notify"
|
||||
},
|
||||
"setup": {
|
||||
"description": "Jednor\u00e1zov\u00e9 heslo bylo odesl\u00e1no prost\u0159ednictv\u00edm **notify.{notify_service}**. Zadejte jej n\u00ed\u017ee:",
|
||||
"title": "Ov\u011b\u0159en\u00ed nastaven\u00ed"
|
||||
}
|
||||
}
|
||||
@@ -20,7 +21,14 @@
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu. Pokud se tato chyba opakuje, ujist\u011bte se, \u017ee hodiny syst\u00e9mu Home Assistant jsou spr\u00e1vn\u011b nastaveny."
|
||||
}
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Chcete-li aktivovat dvoufaktorovou autentizaci pomoc\u00ed jednor\u00e1zov\u00fdch hesel zalo\u017een\u00fdch na \u010dase, na\u010dt\u011bte k\u00f3d QR pomoc\u00ed va\u0161\u00ed autentiza\u010dn\u00ed aplikace. Pokud ji nem\u00e1te, doporu\u010dujeme bu\u010f [Google Authenticator](https://support.google.com/accounts/answer/1066447) nebo [Authy](https://authy.com/). \n\n {qr_code} \n \n Po skenov\u00e1n\u00ed k\u00f3du zadejte \u0161estcifern\u00fd k\u00f3d z aplikace a ov\u011b\u0159te nastaven\u00ed. Pokud m\u00e1te probl\u00e9my se skenov\u00e1n\u00edm k\u00f3du QR, prove\u010fte ru\u010dn\u00ed nastaven\u00ed s k\u00f3dem **`{code}`**.",
|
||||
"title": "Nastavte dvoufaktorovou autentizaci pomoc\u00ed TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "Ni na voljo storitev obve\u0161\u010danja."
|
||||
"no_available_service": "Storitve obve\u0161\u010danja niso na voljo."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Neveljavna koda, poskusite znova."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Prosimo, izberite eno od storitev obve\u0161\u010danja:",
|
||||
"description": "Izberite eno od storitev obve\u0161\u010danja:",
|
||||
"title": "Nastavite enkratno geslo, ki ga dostavite z obvestilno komponento"
|
||||
},
|
||||
"setup": {
|
||||
"description": "Enkratno geslo je poslal **notify.{notify_service} **. Vnesite ga spodaj:",
|
||||
"description": "Enkratno geslo je poslal **notify.{notify_service} **. Prosimo, vnesite ga spodaj:",
|
||||
"title": "Preverite nastavitev"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,12 +16,13 @@ from homeassistant.core import CoreState
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
||||
SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID)
|
||||
SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID,
|
||||
EVENT_AUTOMATION_TRIGGERED, ATTR_NAME)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import extract_domain_configs, script, condition
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import async_get_last_state
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.util.dt import utcnow
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
@@ -93,11 +94,11 @@ PLATFORM_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
})
|
||||
|
||||
TRIGGER_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Optional(ATTR_VARIABLES, default={}): dict,
|
||||
})
|
||||
|
||||
@@ -182,7 +183,7 @@ async def async_setup(hass, config):
|
||||
return True
|
||||
|
||||
|
||||
class AutomationEntity(ToggleEntity):
|
||||
class AutomationEntity(ToggleEntity, RestoreEntity):
|
||||
"""Entity to show status of entity."""
|
||||
|
||||
def __init__(self, automation_id, name, async_attach_triggers, cond_func,
|
||||
@@ -227,12 +228,13 @@ class AutomationEntity(ToggleEntity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Startup with initial state or previous state."""
|
||||
await super().async_added_to_hass()
|
||||
if self._initial_state is not None:
|
||||
enable_automation = self._initial_state
|
||||
_LOGGER.debug("Automation %s initial state %s from config "
|
||||
"initial_state", self.entity_id, enable_automation)
|
||||
else:
|
||||
state = await async_get_last_state(self.hass, self.entity_id)
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
enable_automation = state.state == STATE_ON
|
||||
self._last_triggered = state.attributes.get('last_triggered')
|
||||
@@ -285,12 +287,17 @@ class AutomationEntity(ToggleEntity):
|
||||
"""
|
||||
if skip_condition or self._cond_func(variables):
|
||||
self.async_set_context(context)
|
||||
self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, {
|
||||
ATTR_NAME: self._name,
|
||||
ATTR_ENTITY_ID: self.entity_id,
|
||||
}, context=context)
|
||||
await self._async_action(self.entity_id, variables, context)
|
||||
self._last_triggered = utcnow()
|
||||
await self.async_update_ha_state()
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Remove listeners when removing automation from HASS."""
|
||||
await super().async_will_remove_from_hass()
|
||||
await self.async_turn_off()
|
||||
|
||||
async def async_enable(self):
|
||||
@@ -370,7 +377,13 @@ def _async_get_action(hass, config, name):
|
||||
_LOGGER.info('Executing %s', name)
|
||||
hass.components.logbook.async_log_entry(
|
||||
name, 'has been triggered', DOMAIN, entity_id)
|
||||
await script_obj.async_run(variables, context)
|
||||
|
||||
try:
|
||||
await script_obj.async_run(variables, context)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
script_obj.async_log_exception(
|
||||
_LOGGER,
|
||||
'Error while executing automation {}'.format(entity_id), err)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.alarmdecoder import (
|
||||
ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE,
|
||||
CONF_ZONE_RFID, SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE,
|
||||
CONF_ZONE_RFID, CONF_ZONE_LOOP, SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE,
|
||||
SIGNAL_RFX_MESSAGE, SIGNAL_REL_MESSAGE, CONF_RELAY_ADDR,
|
||||
CONF_RELAY_CHAN)
|
||||
|
||||
@@ -37,10 +37,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
zone_type = device_config_data[CONF_ZONE_TYPE]
|
||||
zone_name = device_config_data[CONF_ZONE_NAME]
|
||||
zone_rfid = device_config_data.get(CONF_ZONE_RFID)
|
||||
zone_loop = device_config_data.get(CONF_ZONE_LOOP)
|
||||
relay_addr = device_config_data.get(CONF_RELAY_ADDR)
|
||||
relay_chan = device_config_data.get(CONF_RELAY_CHAN)
|
||||
device = AlarmDecoderBinarySensor(
|
||||
zone_num, zone_name, zone_type, zone_rfid, relay_addr, relay_chan)
|
||||
zone_num, zone_name, zone_type, zone_rfid, zone_loop, relay_addr,
|
||||
relay_chan)
|
||||
devices.append(device)
|
||||
|
||||
add_entities(devices)
|
||||
@@ -51,7 +53,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
class AlarmDecoderBinarySensor(BinarySensorDevice):
|
||||
"""Representation of an AlarmDecoder binary sensor."""
|
||||
|
||||
def __init__(self, zone_number, zone_name, zone_type, zone_rfid,
|
||||
def __init__(self, zone_number, zone_name, zone_type, zone_rfid, zone_loop,
|
||||
relay_addr, relay_chan):
|
||||
"""Initialize the binary_sensor."""
|
||||
self._zone_number = zone_number
|
||||
@@ -59,6 +61,7 @@ class AlarmDecoderBinarySensor(BinarySensorDevice):
|
||||
self._state = None
|
||||
self._name = zone_name
|
||||
self._rfid = zone_rfid
|
||||
self._loop = zone_loop
|
||||
self._rfstate = None
|
||||
self._relay_addr = relay_addr
|
||||
self._relay_chan = relay_chan
|
||||
@@ -92,14 +95,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice):
|
||||
"""Return the state attributes."""
|
||||
attr = {}
|
||||
if self._rfid and self._rfstate is not None:
|
||||
attr[ATTR_RF_BIT0] = True if self._rfstate & 0x01 else False
|
||||
attr[ATTR_RF_LOW_BAT] = True if self._rfstate & 0x02 else False
|
||||
attr[ATTR_RF_SUPERVISED] = True if self._rfstate & 0x04 else False
|
||||
attr[ATTR_RF_BIT3] = True if self._rfstate & 0x08 else False
|
||||
attr[ATTR_RF_LOOP3] = True if self._rfstate & 0x10 else False
|
||||
attr[ATTR_RF_LOOP2] = True if self._rfstate & 0x20 else False
|
||||
attr[ATTR_RF_LOOP4] = True if self._rfstate & 0x40 else False
|
||||
attr[ATTR_RF_LOOP1] = True if self._rfstate & 0x80 else False
|
||||
attr[ATTR_RF_BIT0] = bool(self._rfstate & 0x01)
|
||||
attr[ATTR_RF_LOW_BAT] = bool(self._rfstate & 0x02)
|
||||
attr[ATTR_RF_SUPERVISED] = bool(self._rfstate & 0x04)
|
||||
attr[ATTR_RF_BIT3] = bool(self._rfstate & 0x08)
|
||||
attr[ATTR_RF_LOOP3] = bool(self._rfstate & 0x10)
|
||||
attr[ATTR_RF_LOOP2] = bool(self._rfstate & 0x20)
|
||||
attr[ATTR_RF_LOOP4] = bool(self._rfstate & 0x40)
|
||||
attr[ATTR_RF_LOOP1] = bool(self._rfstate & 0x80)
|
||||
return attr
|
||||
|
||||
@property
|
||||
@@ -128,6 +131,8 @@ class AlarmDecoderBinarySensor(BinarySensorDevice):
|
||||
"""Update RF state."""
|
||||
if self._rfid and message and message.serial_number == self._rfid:
|
||||
self._rfstate = message.value
|
||||
if self._loop:
|
||||
self._state = 1 if message.loop[self._loop - 1] else 0
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def _rel_message_callback(self, message):
|
||||
|
||||
@@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
data = hass.data[BLINK_DATA]
|
||||
|
||||
devs = []
|
||||
for camera in data.sync.cameras:
|
||||
for camera in data.cameras:
|
||||
for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
|
||||
devs.append(BlinkBinarySensor(data, camera, sensor_type))
|
||||
add_entities(devs, True)
|
||||
@@ -34,7 +34,7 @@ class BlinkBinarySensor(BinarySensorDevice):
|
||||
name, icon = BINARY_SENSORS[sensor_type]
|
||||
self._name = "{} {} {}".format(BLINK_DATA, camera, name)
|
||||
self._icon = icon
|
||||
self._camera = data.sync.cameras[camera]
|
||||
self._camera = data.cameras[camera]
|
||||
self._state = None
|
||||
self._unique_id = "{}-{}".format(self._camera.serial, self._type)
|
||||
|
||||
|
||||
63
homeassistant/components/binary_sensor/esphome.py
Normal file
63
homeassistant/components/binary_sensor/esphome.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Support for ESPHome binary sensors."""
|
||||
import logging
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.esphome import EsphomeEntity, \
|
||||
platform_async_setup_entry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState # noqa
|
||||
|
||||
DEPENDENCIES = ['esphome']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up ESPHome binary sensors based on a config entry."""
|
||||
# pylint: disable=redefined-outer-name
|
||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState # noqa
|
||||
|
||||
await platform_async_setup_entry(
|
||||
hass, entry, async_add_entities,
|
||||
component_key='binary_sensor',
|
||||
info_type=BinarySensorInfo, entity_type=EsphomeBinarySensor,
|
||||
state_type=BinarySensorState
|
||||
)
|
||||
|
||||
|
||||
class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice):
|
||||
"""A binary sensor implementation for ESPHome."""
|
||||
|
||||
@property
|
||||
def _static_info(self) -> 'BinarySensorInfo':
|
||||
return super()._static_info
|
||||
|
||||
@property
|
||||
def _state(self) -> Optional['BinarySensorState']:
|
||||
return super()._state
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
if self._static_info.is_status_binary_sensor:
|
||||
# Status binary sensors indicated connected state.
|
||||
# So in their case what's usually _availability_ is now state
|
||||
return self._entry_data.available
|
||||
if self._state is None:
|
||||
return None
|
||||
return self._state.state
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this device, from component DEVICE_CLASSES."""
|
||||
return self._static_info.device_class
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
if self._static_info.is_status_binary_sensor:
|
||||
return True
|
||||
return super().available
|
||||
@@ -10,12 +10,15 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, ENTITY_ID_FORMAT)
|
||||
from homeassistant.components.fibaro import (
|
||||
FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice)
|
||||
from homeassistant.const import (CONF_DEVICE_CLASS, CONF_ICON)
|
||||
|
||||
DEPENDENCIES = ['fibaro']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_TYPES = {
|
||||
'com.fibaro.floodSensor': ['Flood', 'mdi:water', 'flood'],
|
||||
'com.fibaro.motionSensor': ['Motion', 'mdi:run', 'motion'],
|
||||
'com.fibaro.doorSensor': ['Door', 'mdi:window-open', 'door'],
|
||||
'com.fibaro.windowSensor': ['Window', 'mdi:window-open', 'window'],
|
||||
'com.fibaro.smokeSensor': ['Smoke', 'mdi:smoking', 'smoke'],
|
||||
@@ -43,6 +46,7 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorDevice):
|
||||
super().__init__(fibaro_device, controller)
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
|
||||
stype = None
|
||||
devconf = fibaro_device.device_config
|
||||
if fibaro_device.type in SENSOR_TYPES:
|
||||
stype = fibaro_device.type
|
||||
elif fibaro_device.baseType in SENSOR_TYPES:
|
||||
@@ -53,6 +57,10 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorDevice):
|
||||
else:
|
||||
self._device_class = None
|
||||
self._icon = None
|
||||
# device_config overrides:
|
||||
self._device_class = devconf.get(CONF_DEVICE_CLASS,
|
||||
self._device_class)
|
||||
self._icon = devconf.get(CONF_ICON, self._icon)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,
|
||||
ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE)
|
||||
|
||||
REQUIREMENTS = ['pyhik==0.1.8']
|
||||
REQUIREMENTS = ['pyhik==0.1.9']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_IGNORED = 'ignored'
|
||||
|
||||
@@ -28,14 +28,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the HomematicIP Cloud binary sensor from a config entry."""
|
||||
from homematicip.aio.device import (
|
||||
AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector,
|
||||
AsyncWaterSensor, AsyncRotaryHandleSensor)
|
||||
AsyncWaterSensor, AsyncRotaryHandleSensor,
|
||||
AsyncMotionDetectorPushButton)
|
||||
|
||||
home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
|
||||
devices = []
|
||||
for device in home.devices:
|
||||
if isinstance(device, (AsyncShutterContact, AsyncRotaryHandleSensor)):
|
||||
devices.append(HomematicipShutterContact(home, device))
|
||||
elif isinstance(device, AsyncMotionDetectorIndoor):
|
||||
elif isinstance(device, (AsyncMotionDetectorIndoor,
|
||||
AsyncMotionDetectorPushButton)):
|
||||
devices.append(HomematicipMotionDetector(home, device))
|
||||
elif isinstance(device, AsyncSmokeDetector):
|
||||
devices.append(HomematicipSmokeDetector(home, device))
|
||||
|
||||
@@ -3,59 +3,39 @@
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.ihc/
|
||||
"""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA)
|
||||
BinarySensorDevice)
|
||||
from homeassistant.components.ihc import (
|
||||
validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO)
|
||||
from homeassistant.components.ihc.const import CONF_INVERTING
|
||||
IHC_DATA, IHC_CONTROLLER, IHC_INFO)
|
||||
from homeassistant.components.ihc.const import (
|
||||
CONF_INVERTING)
|
||||
from homeassistant.components.ihc.ihcdevice import IHCDevice
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_TYPE, CONF_ID, CONF_BINARY_SENSORS)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
CONF_TYPE)
|
||||
|
||||
DEPENDENCIES = ['ihc']
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_BINARY_SENSORS, default=[]):
|
||||
vol.All(cv.ensure_list, [
|
||||
vol.All({
|
||||
vol.Required(CONF_ID): cv.positive_int,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_INVERTING, default=False): cv.boolean,
|
||||
}, validate_name)
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the IHC binary sensor platform."""
|
||||
ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER]
|
||||
info = hass.data[IHC_DATA][IHC_INFO]
|
||||
if discovery_info is None:
|
||||
return
|
||||
devices = []
|
||||
if discovery_info:
|
||||
for name, device in discovery_info.items():
|
||||
ihc_id = device['ihc_id']
|
||||
product_cfg = device['product_cfg']
|
||||
product = device['product']
|
||||
sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info,
|
||||
product_cfg.get(CONF_TYPE),
|
||||
product_cfg[CONF_INVERTING],
|
||||
product)
|
||||
devices.append(sensor)
|
||||
else:
|
||||
binary_sensors = config[CONF_BINARY_SENSORS]
|
||||
for sensor_cfg in binary_sensors:
|
||||
ihc_id = sensor_cfg[CONF_ID]
|
||||
name = sensor_cfg[CONF_NAME]
|
||||
sensor_type = sensor_cfg.get(CONF_TYPE)
|
||||
inverting = sensor_cfg[CONF_INVERTING]
|
||||
sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info,
|
||||
sensor_type, inverting)
|
||||
devices.append(sensor)
|
||||
for name, device in discovery_info.items():
|
||||
ihc_id = device['ihc_id']
|
||||
product_cfg = device['product_cfg']
|
||||
product = device['product']
|
||||
# Find controller that corresponds with device id
|
||||
ctrl_id = device['ctrl_id']
|
||||
ihc_key = IHC_DATA.format(ctrl_id)
|
||||
info = hass.data[ihc_key][IHC_INFO]
|
||||
ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
|
||||
|
||||
sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info,
|
||||
product_cfg.get(CONF_TYPE),
|
||||
product_cfg[CONF_INVERTING],
|
||||
product)
|
||||
devices.append(sensor)
|
||||
add_entities(devices)
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ DEPENDENCIES = ['insteon']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_TYPES = {'openClosedSensor': 'opening',
|
||||
'ioLincSensor': 'opening',
|
||||
'motionSensor': 'motion',
|
||||
'doorSensor': 'door',
|
||||
'wetLeakSensor': 'moisture',
|
||||
@@ -58,7 +59,7 @@ class InsteonBinarySensor(InsteonEntity, BinarySensorDevice):
|
||||
on_val = bool(self._insteon_device_state.value)
|
||||
|
||||
if self._insteon_device_state.name in ['lightSensor',
|
||||
'openClosedSensor']:
|
||||
'ioLincSensor']:
|
||||
return not on_val
|
||||
|
||||
return on_val
|
||||
|
||||
@@ -52,7 +52,7 @@ def setup_platform(hass, config: ConfigType,
|
||||
node.nid, node.parent_nid)
|
||||
else:
|
||||
device_type = _detect_device_type(node)
|
||||
subnode_id = int(node.nid[-1])
|
||||
subnode_id = int(node.nid[-1], 16)
|
||||
if device_type in ('opening', 'moisture'):
|
||||
# These sensors use an optional "negative" subnode 2 to snag
|
||||
# all state changes
|
||||
|
||||
@@ -16,10 +16,10 @@ from homeassistant.const import (
|
||||
CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON,
|
||||
CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS, CONF_DEVICE)
|
||||
from homeassistant.components.mqtt import (
|
||||
ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC,
|
||||
ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC,
|
||||
CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS,
|
||||
MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo,
|
||||
subscription)
|
||||
MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
|
||||
MqttEntityDeviceInfo, subscription)
|
||||
from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -45,17 +45,18 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
|
||||
vol.Optional(CONF_OFF_DELAY):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
# Integrations shouldn't never expose unique_id through configuration
|
||||
# this here is an exception because MQTT is a msg transport, not a protocol
|
||||
# Integrations should never expose unique_id through configuration.
|
||||
# This is an exception because MQTT is a message transport, not a protocol
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
|
||||
}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema)
|
||||
}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
|
||||
mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
|
||||
|
||||
|
||||
async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
async_add_entities, discovery_info=None):
|
||||
"""Set up MQTT binary sensor through configuration.yaml."""
|
||||
await _async_setup_entity(hass, config, async_add_entities)
|
||||
await _async_setup_entity(config, async_add_entities)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
@@ -63,7 +64,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
async def async_discover(discovery_payload):
|
||||
"""Discover and add a MQTT binary sensor."""
|
||||
config = PLATFORM_SCHEMA(discovery_payload)
|
||||
await _async_setup_entity(hass, config, async_add_entities,
|
||||
await _async_setup_entity(config, async_add_entities,
|
||||
discovery_payload[ATTR_DISCOVERY_HASH])
|
||||
|
||||
async_dispatcher_connect(
|
||||
@@ -71,50 +72,31 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
async_discover)
|
||||
|
||||
|
||||
async def _async_setup_entity(hass, config, async_add_entities,
|
||||
discovery_hash=None):
|
||||
async def _async_setup_entity(config, async_add_entities, discovery_hash=None):
|
||||
"""Set up the MQTT binary sensor."""
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
if value_template is not None:
|
||||
value_template.hass = hass
|
||||
|
||||
async_add_entities([MqttBinarySensor(
|
||||
config,
|
||||
discovery_hash
|
||||
)])
|
||||
async_add_entities([MqttBinarySensor(config, discovery_hash)])
|
||||
|
||||
|
||||
class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate,
|
||||
class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
|
||||
MqttEntityDeviceInfo, BinarySensorDevice):
|
||||
"""Representation a binary sensor that is updated by MQTT."""
|
||||
|
||||
def __init__(self, config, discovery_hash):
|
||||
"""Initialize the MQTT binary sensor."""
|
||||
self._config = config
|
||||
self._unique_id = config.get(CONF_UNIQUE_ID)
|
||||
self._state = None
|
||||
self._sub_state = None
|
||||
self._delay_listener = None
|
||||
|
||||
self._name = None
|
||||
self._state_topic = None
|
||||
self._device_class = None
|
||||
self._payload_on = None
|
||||
self._payload_off = None
|
||||
self._qos = None
|
||||
self._force_update = None
|
||||
self._off_delay = None
|
||||
self._template = None
|
||||
self._unique_id = None
|
||||
|
||||
# Load config
|
||||
self._setup_from_config(config)
|
||||
|
||||
availability_topic = config.get(CONF_AVAILABILITY_TOPIC)
|
||||
payload_available = config.get(CONF_PAYLOAD_AVAILABLE)
|
||||
payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE)
|
||||
qos = config.get(CONF_QOS)
|
||||
device_config = config.get(CONF_DEVICE)
|
||||
|
||||
MqttAvailability.__init__(self, availability_topic, self._qos,
|
||||
MqttAttributes.__init__(self, config)
|
||||
MqttAvailability.__init__(self, availability_topic, qos,
|
||||
payload_available, payload_not_available)
|
||||
MqttDiscoveryUpdate.__init__(self, discovery_hash,
|
||||
self.discovery_update)
|
||||
@@ -122,37 +104,24 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate,
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe mqtt events."""
|
||||
await MqttAvailability.async_added_to_hass(self)
|
||||
await MqttDiscoveryUpdate.async_added_to_hass(self)
|
||||
await super().async_added_to_hass()
|
||||
await self._subscribe_topics()
|
||||
|
||||
async def discovery_update(self, discovery_payload):
|
||||
"""Handle updated discovery message."""
|
||||
config = PLATFORM_SCHEMA(discovery_payload)
|
||||
self._setup_from_config(config)
|
||||
self._config = config
|
||||
await self.attributes_discovery_update(config)
|
||||
await self.availability_discovery_update(config)
|
||||
await self._subscribe_topics()
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
def _setup_from_config(self, config):
|
||||
"""(Re)Setup the entity."""
|
||||
self._name = config.get(CONF_NAME)
|
||||
self._state_topic = config.get(CONF_STATE_TOPIC)
|
||||
self._device_class = config.get(CONF_DEVICE_CLASS)
|
||||
self._qos = config.get(CONF_QOS)
|
||||
self._force_update = config.get(CONF_FORCE_UPDATE)
|
||||
self._off_delay = config.get(CONF_OFF_DELAY)
|
||||
self._payload_on = config.get(CONF_PAYLOAD_ON)
|
||||
self._payload_off = config.get(CONF_PAYLOAD_OFF)
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
if value_template is not None and value_template.hass is None:
|
||||
value_template.hass = self.hass
|
||||
self._template = value_template
|
||||
|
||||
self._unique_id = config.get(CONF_UNIQUE_ID)
|
||||
|
||||
async def _subscribe_topics(self):
|
||||
"""(Re)Subscribe to topics."""
|
||||
value_template = self._config.get(CONF_VALUE_TEMPLATE)
|
||||
if value_template is not None:
|
||||
value_template.hass = self.hass
|
||||
|
||||
@callback
|
||||
def off_delay_listener(now):
|
||||
"""Switch device off after a delay."""
|
||||
@@ -163,38 +132,43 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate,
|
||||
@callback
|
||||
def state_message_received(_topic, payload, _qos):
|
||||
"""Handle a new received MQTT state message."""
|
||||
if self._template is not None:
|
||||
payload = self._template.async_render_with_possible_json_value(
|
||||
payload)
|
||||
if payload == self._payload_on:
|
||||
value_template = self._config.get(CONF_VALUE_TEMPLATE)
|
||||
if value_template is not None:
|
||||
payload = value_template.async_render_with_possible_json_value(
|
||||
payload, variables={'entity_id': self.entity_id})
|
||||
if payload == self._config.get(CONF_PAYLOAD_ON):
|
||||
self._state = True
|
||||
elif payload == self._payload_off:
|
||||
elif payload == self._config.get(CONF_PAYLOAD_OFF):
|
||||
self._state = False
|
||||
else: # Payload is not for this entity
|
||||
_LOGGER.warning('No matching payload found'
|
||||
' for entity: %s with state_topic: %s',
|
||||
self._name, self._state_topic)
|
||||
self._config.get(CONF_NAME),
|
||||
self._config.get(CONF_STATE_TOPIC))
|
||||
return
|
||||
|
||||
if self._delay_listener is not None:
|
||||
self._delay_listener()
|
||||
self._delay_listener = None
|
||||
|
||||
if (self._state and self._off_delay is not None):
|
||||
off_delay = self._config.get(CONF_OFF_DELAY)
|
||||
if (self._state and off_delay is not None):
|
||||
self._delay_listener = evt.async_call_later(
|
||||
self.hass, self._off_delay, off_delay_listener)
|
||||
self.hass, off_delay, off_delay_listener)
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
self._sub_state = await subscription.async_subscribe_topics(
|
||||
self.hass, self._sub_state,
|
||||
{'state_topic': {'topic': self._state_topic,
|
||||
{'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC),
|
||||
'msg_callback': state_message_received,
|
||||
'qos': self._qos}})
|
||||
'qos': self._config.get(CONF_QOS)}})
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Unsubscribe when removed."""
|
||||
await subscription.async_unsubscribe_topics(self.hass, self._sub_state)
|
||||
self._sub_state = await subscription.async_unsubscribe_topics(
|
||||
self.hass, self._sub_state)
|
||||
await MqttAttributes.async_will_remove_from_hass(self)
|
||||
await MqttAvailability.async_will_remove_from_hass(self)
|
||||
|
||||
@property
|
||||
@@ -205,7 +179,7 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate,
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the binary sensor."""
|
||||
return self._name
|
||||
return self._config.get(CONF_NAME)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
@@ -215,12 +189,12 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate,
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor."""
|
||||
return self._device_class
|
||||
return self._config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
@property
|
||||
def force_update(self):
|
||||
"""Force update."""
|
||||
return self._force_update
|
||||
return self._config.get(CONF_FORCE_UPDATE)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
|
||||
@@ -61,8 +61,7 @@ class MyStromView(HomeAssistantView):
|
||||
'{}_{}'.format(button_id, button_action))
|
||||
self.add_entities([self.buttons[entity_id]])
|
||||
else:
|
||||
new_state = True if self.buttons[entity_id].state == 'off' \
|
||||
else False
|
||||
new_state = self.buttons[entity_id].state == 'off'
|
||||
self.buttons[entity_id].async_on_update(new_state)
|
||||
|
||||
|
||||
|
||||
81
homeassistant/components/binary_sensor/ness_alarm.py
Normal file
81
homeassistant/components/binary_sensor/ness_alarm.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Support for Ness D8X/D16X zone states - represented as binary sensors.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.ness_alarm/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.ness_alarm import (
|
||||
CONF_ZONES, CONF_ZONE_TYPE, CONF_ZONE_NAME, CONF_ZONE_ID,
|
||||
SIGNAL_ZONE_CHANGED, ZoneChangedData)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
DEPENDENCIES = ['ness_alarm']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up the Ness Alarm binary sensor devices."""
|
||||
if not discovery_info:
|
||||
return
|
||||
|
||||
configured_zones = discovery_info[CONF_ZONES]
|
||||
|
||||
devices = []
|
||||
|
||||
for zone_config in configured_zones:
|
||||
zone_type = zone_config[CONF_ZONE_TYPE]
|
||||
zone_name = zone_config[CONF_ZONE_NAME]
|
||||
zone_id = zone_config[CONF_ZONE_ID]
|
||||
device = NessZoneBinarySensor(zone_id=zone_id, name=zone_name,
|
||||
zone_type=zone_type)
|
||||
devices.append(device)
|
||||
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class NessZoneBinarySensor(BinarySensorDevice):
|
||||
"""Representation of an Ness alarm zone as a binary sensor."""
|
||||
|
||||
def __init__(self, zone_id, name, zone_type):
|
||||
"""Initialize the binary_sensor."""
|
||||
self._zone_id = zone_id
|
||||
self._name = name
|
||||
self._type = zone_type
|
||||
self._state = 0
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_ZONE_CHANGED, self._handle_zone_change)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._state == 1
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return self._type
|
||||
|
||||
@callback
|
||||
def _handle_zone_change(self, data: ZoneChangedData):
|
||||
"""Handle zone state update."""
|
||||
if self._zone_id == data.zone_id:
|
||||
self._state = data.state
|
||||
self.async_schedule_update_ha_state()
|
||||
@@ -7,10 +7,10 @@ https://home-assistant.io/components/binary_sensor.point/
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
|
||||
from homeassistant.components.point import MinutPointEntity
|
||||
from homeassistant.components.point.const import (
|
||||
DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK)
|
||||
DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
@@ -40,10 +40,16 @@ EVENTS = {
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up a Point's binary sensors based on a config entry."""
|
||||
device_id = config_entry.data[NEW_DEVICE]
|
||||
client = hass.data[POINT_DOMAIN][config_entry.entry_id]
|
||||
async_add_entities((MinutPointBinarySensor(client, device_id, device_class)
|
||||
for device_class in EVENTS), True)
|
||||
async def async_discover_sensor(device_id):
|
||||
"""Discover and add a discovered sensor."""
|
||||
client = hass.data[POINT_DOMAIN][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
(MinutPointBinarySensor(client, device_id, device_class)
|
||||
for device_class in EVENTS), True)
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass, POINT_DISCOVERY_NEW.format(DOMAIN, POINT_DOMAIN),
|
||||
async_discover_sensor)
|
||||
|
||||
|
||||
class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice):
|
||||
|
||||
@@ -74,7 +74,7 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice):
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
@callback
|
||||
def update(self):
|
||||
def update():
|
||||
"""Update the state."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.satel_integra import (CONF_ZONES,
|
||||
CONF_OUTPUTS,
|
||||
CONF_ZONE_NAME,
|
||||
CONF_ZONE_TYPE,
|
||||
SIGNAL_ZONES_UPDATED)
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
SIGNAL_OUTPUTS_UPDATED)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
@@ -32,7 +34,17 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
for zone_num, device_config_data in configured_zones.items():
|
||||
zone_type = device_config_data[CONF_ZONE_TYPE]
|
||||
zone_name = device_config_data[CONF_ZONE_NAME]
|
||||
device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type)
|
||||
device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type,
|
||||
SIGNAL_ZONES_UPDATED)
|
||||
devices.append(device)
|
||||
|
||||
configured_outputs = discovery_info[CONF_OUTPUTS]
|
||||
|
||||
for zone_num, device_config_data in configured_outputs.items():
|
||||
zone_type = device_config_data[CONF_ZONE_TYPE]
|
||||
zone_name = device_config_data[CONF_ZONE_NAME]
|
||||
device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type,
|
||||
SIGNAL_OUTPUTS_UPDATED)
|
||||
devices.append(device)
|
||||
|
||||
async_add_entities(devices)
|
||||
@@ -41,17 +53,18 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
class SatelIntegraBinarySensor(BinarySensorDevice):
|
||||
"""Representation of an Satel Integra binary sensor."""
|
||||
|
||||
def __init__(self, zone_number, zone_name, zone_type):
|
||||
def __init__(self, device_number, device_name, zone_type, react_to_signal):
|
||||
"""Initialize the binary_sensor."""
|
||||
self._zone_number = zone_number
|
||||
self._name = zone_name
|
||||
self._device_number = device_number
|
||||
self._name = device_name
|
||||
self._zone_type = zone_type
|
||||
self._state = 0
|
||||
self._react_to_signal = react_to_signal
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_ZONES_UPDATED, self._zones_updated)
|
||||
self.hass, self._react_to_signal, self._devices_updated)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -80,9 +93,9 @@ class SatelIntegraBinarySensor(BinarySensorDevice):
|
||||
return self._zone_type
|
||||
|
||||
@callback
|
||||
def _zones_updated(self, zones):
|
||||
def _devices_updated(self, zones):
|
||||
"""Update the zone's state, if needed."""
|
||||
if self._zone_number in zones \
|
||||
and self._state != zones[self._zone_number]:
|
||||
self._state = zones[self._zone_number]
|
||||
if self._device_number in zones \
|
||||
and self._state != zones[self._device_number]:
|
||||
self._state = zones[self._device_number]
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@@ -14,46 +14,48 @@ DEPENDENCIES = ['sense']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BIN_SENSOR_CLASS = 'power'
|
||||
MDI_ICONS = {'ac': 'air-conditioner',
|
||||
'aquarium': 'fish',
|
||||
'car': 'car-electric',
|
||||
'computer': 'desktop-classic',
|
||||
'cup': 'coffee',
|
||||
'dehumidifier': 'water-off',
|
||||
'dishes': 'dishwasher',
|
||||
'drill': 'toolbox',
|
||||
'fan': 'fan',
|
||||
'freezer': 'fridge-top',
|
||||
'fridge': 'fridge-bottom',
|
||||
'game': 'gamepad-variant',
|
||||
'garage': 'garage',
|
||||
'grill': 'stove',
|
||||
'heat': 'fire',
|
||||
'heater': 'radiatior',
|
||||
'humidifier': 'water',
|
||||
'kettle': 'kettle',
|
||||
'leafblower': 'leaf',
|
||||
'lightbulb': 'lightbulb',
|
||||
'media_console': 'set-top-box',
|
||||
'modem': 'router-wireless',
|
||||
'outlet': 'power-socket-us',
|
||||
'papershredder': 'shredder',
|
||||
'printer': 'printer',
|
||||
'pump': 'water-pump',
|
||||
'settings': 'settings',
|
||||
'skillet': 'pot',
|
||||
'smartcamera': 'webcam',
|
||||
'socket': 'power-plug',
|
||||
'sound': 'speaker',
|
||||
'stove': 'stove',
|
||||
'trash': 'trash-can',
|
||||
'tv': 'television',
|
||||
'vacuum': 'robot-vacuum',
|
||||
'washer': 'washing-machine'}
|
||||
MDI_ICONS = {
|
||||
'ac': 'air-conditioner',
|
||||
'aquarium': 'fish',
|
||||
'car': 'car-electric',
|
||||
'computer': 'desktop-classic',
|
||||
'cup': 'coffee',
|
||||
'dehumidifier': 'water-off',
|
||||
'dishes': 'dishwasher',
|
||||
'drill': 'toolbox',
|
||||
'fan': 'fan',
|
||||
'freezer': 'fridge-top',
|
||||
'fridge': 'fridge-bottom',
|
||||
'game': 'gamepad-variant',
|
||||
'garage': 'garage',
|
||||
'grill': 'stove',
|
||||
'heat': 'fire',
|
||||
'heater': 'radiatior',
|
||||
'humidifier': 'water',
|
||||
'kettle': 'kettle',
|
||||
'leafblower': 'leaf',
|
||||
'lightbulb': 'lightbulb',
|
||||
'media_console': 'set-top-box',
|
||||
'modem': 'router-wireless',
|
||||
'outlet': 'power-socket-us',
|
||||
'papershredder': 'shredder',
|
||||
'printer': 'printer',
|
||||
'pump': 'water-pump',
|
||||
'settings': 'settings',
|
||||
'skillet': 'pot',
|
||||
'smartcamera': 'webcam',
|
||||
'socket': 'power-plug',
|
||||
'sound': 'speaker',
|
||||
'stove': 'stove',
|
||||
'trash': 'trash-can',
|
||||
'tv': 'television',
|
||||
'vacuum': 'robot-vacuum',
|
||||
'washer': 'washing-machine',
|
||||
}
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Sense sensor."""
|
||||
"""Set up the Sense binary sensor."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
@@ -67,14 +69,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
|
||||
def sense_to_mdi(sense_icon):
|
||||
"""Convert sense icon to mdi icon."""
|
||||
return 'mdi:' + MDI_ICONS.get(sense_icon, 'power-plug')
|
||||
return 'mdi:{}'.format(MDI_ICONS.get(sense_icon, 'power-plug'))
|
||||
|
||||
|
||||
class SenseDevice(BinarySensorDevice):
|
||||
"""Implementation of a Sense energy device binary sensor."""
|
||||
|
||||
def __init__(self, data, device):
|
||||
"""Initialize the sensor."""
|
||||
"""Initialize the Sense binary sensor."""
|
||||
self._name = device['name']
|
||||
self._id = device['id']
|
||||
self._icon = sense_to_mdi(device['icon'])
|
||||
|
||||
@@ -41,6 +41,7 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice):
|
||||
self._state = None
|
||||
self._icon = None
|
||||
self._battery = None
|
||||
self._available = False
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
@@ -71,6 +72,11 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice):
|
||||
attr[ATTR_BATTERY_LEVEL] = self._battery
|
||||
return attr
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return self._available
|
||||
|
||||
def update(self):
|
||||
"""Update the state."""
|
||||
self.controller.get_states([self.tahoma_device])
|
||||
@@ -82,11 +88,13 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice):
|
||||
self._state = STATE_ON
|
||||
|
||||
if 'core:SensorDefectState' in self.tahoma_device.active_states:
|
||||
# Set to 'lowBattery' for low battery warning.
|
||||
# 'lowBattery' for low battery warning. 'dead' for not available.
|
||||
self._battery = self.tahoma_device.active_states[
|
||||
'core:SensorDefectState']
|
||||
self._available = bool(self._battery != 'dead')
|
||||
else:
|
||||
self._battery = None
|
||||
self._available = True
|
||||
|
||||
if self._state == STATE_ON:
|
||||
self._icon = "mdi:fire"
|
||||
|
||||
@@ -9,20 +9,35 @@ https://home-assistant.io/components/binary_sensor.tellduslive/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.tellduslive import TelldusLiveEntity
|
||||
from homeassistant.components import binary_sensor, tellduslive
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.tellduslive.entry import TelldusLiveEntity
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up Tellstick sensors."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
add_entities(
|
||||
TelldusLiveSensor(hass, binary_sensor)
|
||||
for binary_sensor in discovery_info
|
||||
)
|
||||
"""Old way of setting up TelldusLive.
|
||||
|
||||
Can only be called when a user accidentally mentions the platform in their
|
||||
config. But even in that case it would have been ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up tellduslive sensors dynamically."""
|
||||
async def async_discover_binary_sensor(device_id):
|
||||
"""Discover and add a discovered sensor."""
|
||||
client = hass.data[tellduslive.DOMAIN]
|
||||
async_add_entities([TelldusLiveSensor(client, device_id)])
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
tellduslive.TELLDUS_DISCOVERY_NEW.format(binary_sensor.DOMAIN,
|
||||
tellduslive.DOMAIN),
|
||||
async_discover_binary_sensor)
|
||||
|
||||
|
||||
class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice):
|
||||
|
||||
@@ -6,17 +6,19 @@ https://home-assistant.io/components/binary_sensor.volvooncall/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.volvooncall import VolvoEntity
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, DEVICE_CLASSES)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up the Volvo sensors."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
add_entities([VolvoSensor(hass, *discovery_info)])
|
||||
async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)])
|
||||
|
||||
|
||||
class VolvoSensor(VolvoEntity, BinarySensorDevice):
|
||||
@@ -25,14 +27,11 @@ class VolvoSensor(VolvoEntity, BinarySensorDevice):
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the binary sensor is on."""
|
||||
val = getattr(self.vehicle, self._attribute)
|
||||
if self._attribute == 'bulb_failures':
|
||||
return bool(val)
|
||||
if self._attribute in ['doors', 'windows']:
|
||||
return any([val[key] for key in val if 'Open' in key])
|
||||
return val != 'Normal'
|
||||
return self.instrument.is_on
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return 'safety'
|
||||
if self.instrument.device_class in DEVICE_CLASSES:
|
||||
return self.instrument.device_class
|
||||
return None
|
||||
|
||||
@@ -4,7 +4,10 @@ Support for WeMo sensors.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.wemo/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import async_timeout
|
||||
import requests
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
@@ -15,7 +18,7 @@ DEPENDENCIES = ['wemo']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities_callback, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Register discovered WeMo binary sensors."""
|
||||
from pywemo import discovery
|
||||
|
||||
@@ -31,7 +34,7 @@ def setup_platform(hass, config, add_entities_callback, discovery_info=None):
|
||||
raise PlatformNotReady
|
||||
|
||||
if device:
|
||||
add_entities_callback([WemoBinarySensor(hass, device)])
|
||||
add_entities([WemoBinarySensor(hass, device)])
|
||||
|
||||
|
||||
class WemoBinarySensor(BinarySensorDevice):
|
||||
@@ -41,48 +44,90 @@ class WemoBinarySensor(BinarySensorDevice):
|
||||
"""Initialize the WeMo sensor."""
|
||||
self.wemo = device
|
||||
self._state = None
|
||||
self._available = True
|
||||
self._update_lock = None
|
||||
self._model_name = self.wemo.model_name
|
||||
self._name = self.wemo.name
|
||||
self._serialnumber = self.wemo.serialnumber
|
||||
|
||||
wemo = hass.components.wemo
|
||||
wemo.SUBSCRIPTION_REGISTRY.register(self.wemo)
|
||||
wemo.SUBSCRIPTION_REGISTRY.on(self.wemo, None, self._update_callback)
|
||||
|
||||
def _update_callback(self, _device, _type, _params):
|
||||
"""Handle state changes."""
|
||||
_LOGGER.info("Subscription update for %s", _device)
|
||||
def _subscription_callback(self, _device, _type, _params):
|
||||
"""Update the state by the Wemo sensor."""
|
||||
_LOGGER.debug("Subscription update for %s", self.name)
|
||||
updated = self.wemo.subscription_update(_type, _params)
|
||||
self._update(force_update=(not updated))
|
||||
self.hass.add_job(
|
||||
self._async_locked_subscription_callback(not updated))
|
||||
|
||||
if not hasattr(self, 'hass'):
|
||||
async def _async_locked_subscription_callback(self, force_update):
|
||||
"""Handle an update from a subscription."""
|
||||
# If an update is in progress, we don't do anything
|
||||
if self._update_lock.locked():
|
||||
return
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed with subscriptions."""
|
||||
return False
|
||||
await self._async_locked_update(force_update)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Wemo sensor added to HASS."""
|
||||
# Define inside async context so we know our event loop
|
||||
self._update_lock = asyncio.Lock()
|
||||
|
||||
registry = self.hass.components.wemo.SUBSCRIPTION_REGISTRY
|
||||
await self.hass.async_add_executor_job(registry.register, self.wemo)
|
||||
registry.on(self.wemo, None, self._subscription_callback)
|
||||
|
||||
async def async_update(self):
|
||||
"""Update WeMo state.
|
||||
|
||||
Wemo has an aggressive retry logic that sometimes can take over a
|
||||
minute to return. If we don't get a state after 5 seconds, assume the
|
||||
Wemo sensor is unreachable. If update goes through, it will be made
|
||||
available again.
|
||||
"""
|
||||
# If an update is in progress, we don't do anything
|
||||
if self._update_lock.locked():
|
||||
return
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
await asyncio.shield(self._async_locked_update(True))
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning('Lost connection to %s', self.name)
|
||||
self._available = False
|
||||
|
||||
async def _async_locked_update(self, force_update):
|
||||
"""Try updating within an async lock."""
|
||||
async with self._update_lock:
|
||||
await self.hass.async_add_executor_job(self._update, force_update)
|
||||
|
||||
def _update(self, force_update=True):
|
||||
"""Update the sensor state."""
|
||||
try:
|
||||
self._state = self.wemo.get_state(force_update)
|
||||
|
||||
if not self._available:
|
||||
_LOGGER.info('Reconnected to %s', self.name)
|
||||
self._available = True
|
||||
except AttributeError as err:
|
||||
_LOGGER.warning("Could not update status for %s (%s)",
|
||||
self.name, err)
|
||||
self._available = False
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the id of this WeMo device."""
|
||||
return self.wemo.serialnumber
|
||||
"""Return the id of this WeMo sensor."""
|
||||
return self._serialnumber
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the service if any."""
|
||||
return self.wemo.name
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._state
|
||||
|
||||
def update(self):
|
||||
"""Update WeMo state."""
|
||||
self._update(force_update=True)
|
||||
|
||||
def _update(self, force_update=True):
|
||||
try:
|
||||
self._state = self.wemo.get_state(force_update)
|
||||
except AttributeError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not update status for %s (%s)", self.name, err)
|
||||
@property
|
||||
def available(self):
|
||||
"""Return true if sensor is available."""
|
||||
return self._available
|
||||
|
||||
@@ -409,10 +409,14 @@ class XiaomiButton(XiaomiBinarySensor):
|
||||
click_type = 'double'
|
||||
elif value == 'both_click':
|
||||
click_type = 'both'
|
||||
elif value == 'double_both_click':
|
||||
click_type = 'double_both'
|
||||
elif value == 'shake':
|
||||
click_type = 'shake'
|
||||
elif value in ['long_click', 'long_both_click']:
|
||||
return False
|
||||
elif value == 'long_click':
|
||||
click_type = 'long'
|
||||
elif value == 'long_both_click':
|
||||
click_type = 'long_both'
|
||||
else:
|
||||
_LOGGER.warning("Unsupported click_type detected: %s", value)
|
||||
return False
|
||||
@@ -423,9 +427,7 @@ class XiaomiButton(XiaomiBinarySensor):
|
||||
})
|
||||
self._last_action = click_type
|
||||
|
||||
if value in ['long_click_press', 'long_click_release']:
|
||||
return True
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class XiaomiCube(XiaomiBinarySensor):
|
||||
@@ -467,4 +469,12 @@ class XiaomiCube(XiaomiBinarySensor):
|
||||
})
|
||||
self._last_action = 'rotate'
|
||||
|
||||
if 'rotate_degree' in data:
|
||||
self._hass.bus.fire('xiaomi_aqara.cube_action', {
|
||||
'entity_id': self.entity_id,
|
||||
'action_type': 'rotate',
|
||||
'action_value': float(data['rotate_degree'].replace(",", "."))
|
||||
})
|
||||
self._last_action = 'rotate'
|
||||
|
||||
return True
|
||||
|
||||
@@ -7,7 +7,16 @@ at https://home-assistant.io/components/binary_sensor.zha/
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
|
||||
from homeassistant.components import zha
|
||||
from homeassistant.components.zha import helpers
|
||||
from homeassistant.components.zha.const import (
|
||||
DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW)
|
||||
from homeassistant.components.zha.entities import ZhaEntity
|
||||
from homeassistant.const import STATE_ON
|
||||
from homeassistant.components.zha.entities.listeners import (
|
||||
OnOffListener, LevelListener
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,34 +31,56 @@ CLASS_MAPPING = {
|
||||
0x002b: 'gas',
|
||||
0x002d: 'vibration',
|
||||
}
|
||||
DEVICE_CLASS_OCCUPANCY = 'occupancy'
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up the Zigbee Home Automation binary sensors."""
|
||||
discovery_info = zha.get_discovery_info(hass, discovery_info)
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Old way of setting up Zigbee Home Automation binary sensors."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Zigbee Home Automation binary sensor from config entry."""
|
||||
async def async_discover(discovery_info):
|
||||
await _async_setup_entities(hass, config_entry, async_add_entities,
|
||||
[discovery_info])
|
||||
|
||||
unsub = async_dispatcher_connect(
|
||||
hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover)
|
||||
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
|
||||
|
||||
binary_sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
|
||||
if binary_sensors is not None:
|
||||
await _async_setup_entities(hass, config_entry, async_add_entities,
|
||||
binary_sensors.values())
|
||||
del hass.data[DATA_ZHA][DOMAIN]
|
||||
|
||||
|
||||
async def _async_setup_entities(hass, config_entry, async_add_entities,
|
||||
discovery_infos):
|
||||
"""Set up the ZHA binary sensors."""
|
||||
from zigpy.zcl.clusters.general import OnOff
|
||||
from zigpy.zcl.clusters.measurement import OccupancySensing
|
||||
from zigpy.zcl.clusters.security import IasZone
|
||||
if IasZone.cluster_id in discovery_info['in_clusters']:
|
||||
await _async_setup_iaszone(hass, config, async_add_entities,
|
||||
discovery_info)
|
||||
elif OnOff.cluster_id in discovery_info['out_clusters']:
|
||||
await _async_setup_remote(hass, config, async_add_entities,
|
||||
discovery_info)
|
||||
|
||||
entities = []
|
||||
for discovery_info in discovery_infos:
|
||||
if IasZone.cluster_id in discovery_info['in_clusters']:
|
||||
entities.append(await _async_setup_iaszone(discovery_info))
|
||||
elif OccupancySensing.cluster_id in discovery_info['in_clusters']:
|
||||
entities.append(
|
||||
BinarySensor(DEVICE_CLASS_OCCUPANCY, **discovery_info))
|
||||
elif OnOff.cluster_id in discovery_info['out_clusters']:
|
||||
entities.append(Remote(**discovery_info))
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
async def _async_setup_iaszone(hass, config, async_add_entities,
|
||||
discovery_info):
|
||||
async def _async_setup_iaszone(discovery_info):
|
||||
device_class = None
|
||||
from zigpy.zcl.clusters.security import IasZone
|
||||
cluster = discovery_info['in_clusters'][IasZone.cluster_id]
|
||||
if discovery_info['new_join']:
|
||||
await cluster.bind()
|
||||
ieee = cluster.endpoint.device.application.ieee
|
||||
await cluster.write_attributes({'cie_addr': ieee})
|
||||
|
||||
try:
|
||||
zone_type = await cluster['zone_type']
|
||||
@@ -58,36 +89,11 @@ async def _async_setup_iaszone(hass, config, async_add_entities,
|
||||
# If we fail to read from the device, use a non-specific class
|
||||
pass
|
||||
|
||||
sensor = BinarySensor(device_class, **discovery_info)
|
||||
async_add_entities([sensor], update_before_add=True)
|
||||
return IasZoneSensor(device_class, **discovery_info)
|
||||
|
||||
|
||||
async def _async_setup_remote(hass, config, async_add_entities,
|
||||
discovery_info):
|
||||
|
||||
remote = Remote(**discovery_info)
|
||||
|
||||
if discovery_info['new_join']:
|
||||
from zigpy.zcl.clusters.general import OnOff, LevelControl
|
||||
out_clusters = discovery_info['out_clusters']
|
||||
if OnOff.cluster_id in out_clusters:
|
||||
cluster = out_clusters[OnOff.cluster_id]
|
||||
await zha.configure_reporting(
|
||||
remote.entity_id, cluster, 0, min_report=0, max_report=600,
|
||||
reportable_change=1
|
||||
)
|
||||
if LevelControl.cluster_id in out_clusters:
|
||||
cluster = out_clusters[LevelControl.cluster_id]
|
||||
await zha.configure_reporting(
|
||||
remote.entity_id, cluster, 0, min_report=1, max_report=600,
|
||||
reportable_change=1
|
||||
)
|
||||
|
||||
async_add_entities([remote], update_before_add=True)
|
||||
|
||||
|
||||
class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
"""The ZHA Binary Sensor."""
|
||||
class IasZoneSensor(RestoreEntity, ZhaEntity, BinarySensorDevice):
|
||||
"""The IasZoneSensor Binary Sensor."""
|
||||
|
||||
_domain = DOMAIN
|
||||
|
||||
@@ -98,11 +104,6 @@ class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
from zigpy.zcl.clusters.security import IasZone
|
||||
self._ias_zone_cluster = self._in_clusters[IasZone.cluster_id]
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Let zha handle polling."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if entity is on."""
|
||||
@@ -126,94 +127,66 @@ class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
res = self._ias_zone_cluster.enroll_response(0, 0)
|
||||
self.hass.async_add_job(res)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
old_state = await self.async_get_last_state()
|
||||
if self._state is not None or old_state is None:
|
||||
return
|
||||
|
||||
_LOGGER.debug("%s restoring old state: %s", self.entity_id, old_state)
|
||||
if old_state.state == STATE_ON:
|
||||
self._state = 3
|
||||
else:
|
||||
self._state = 0
|
||||
|
||||
async def async_configure(self):
|
||||
"""Configure IAS device."""
|
||||
await self._ias_zone_cluster.bind()
|
||||
ieee = self._ias_zone_cluster.endpoint.device.application.ieee
|
||||
await self._ias_zone_cluster.write_attributes({'cie_addr': ieee})
|
||||
_LOGGER.debug("%s: finished configuration", self.entity_id)
|
||||
|
||||
async def async_update(self):
|
||||
"""Retrieve latest state."""
|
||||
from zigpy.types.basic import uint16_t
|
||||
|
||||
result = await zha.safe_read(self._endpoint.ias_zone,
|
||||
['zone_status'],
|
||||
allow_cache=False,
|
||||
only_cache=(not self._initialized))
|
||||
result = await helpers.safe_read(self._endpoint.ias_zone,
|
||||
['zone_status'],
|
||||
allow_cache=False,
|
||||
only_cache=(not self._initialized))
|
||||
state = result.get('zone_status', self._state)
|
||||
if isinstance(state, (int, uint16_t)):
|
||||
self._state = result.get('zone_status', self._state) & 3
|
||||
|
||||
|
||||
class Remote(zha.Entity, BinarySensorDevice):
|
||||
class Remote(RestoreEntity, ZhaEntity, BinarySensorDevice):
|
||||
"""ZHA switch/remote controller/button."""
|
||||
|
||||
_domain = DOMAIN
|
||||
|
||||
class OnOffListener:
|
||||
"""Listener for the OnOff Zigbee cluster."""
|
||||
|
||||
def __init__(self, entity):
|
||||
"""Initialize OnOffListener."""
|
||||
self._entity = entity
|
||||
|
||||
def cluster_command(self, tsn, command_id, args):
|
||||
"""Handle commands received to this cluster."""
|
||||
if command_id in (0x0000, 0x0040):
|
||||
self._entity.set_state(False)
|
||||
elif command_id in (0x0001, 0x0041, 0x0042):
|
||||
self._entity.set_state(True)
|
||||
elif command_id == 0x0002:
|
||||
self._entity.set_state(not self._entity.is_on)
|
||||
|
||||
def attribute_updated(self, attrid, value):
|
||||
"""Handle attribute updates on this cluster."""
|
||||
if attrid == 0:
|
||||
self._entity.set_state(value)
|
||||
|
||||
def zdo_command(self, *args, **kwargs):
|
||||
"""Handle ZDO commands on this cluster."""
|
||||
pass
|
||||
|
||||
class LevelListener:
|
||||
"""Listener for the LevelControl Zigbee cluster."""
|
||||
|
||||
def __init__(self, entity):
|
||||
"""Initialize LevelListener."""
|
||||
self._entity = entity
|
||||
|
||||
def cluster_command(self, tsn, command_id, args):
|
||||
"""Handle commands received to this cluster."""
|
||||
if command_id in (0x0000, 0x0004): # move_to_level, -with_on_off
|
||||
self._entity.set_level(args[0])
|
||||
elif command_id in (0x0001, 0x0005): # move, -with_on_off
|
||||
# We should dim slowly -- for now, just step once
|
||||
rate = args[1]
|
||||
if args[0] == 0xff:
|
||||
rate = 10 # Should read default move rate
|
||||
self._entity.move_level(-rate if args[0] else rate)
|
||||
elif command_id in (0x0002, 0x0006): # step, -with_on_off
|
||||
# Step (technically may change on/off)
|
||||
self._entity.move_level(-args[1] if args[0] else args[1])
|
||||
|
||||
def attribute_update(self, attrid, value):
|
||||
"""Handle attribute updates on this cluster."""
|
||||
if attrid == 0:
|
||||
self._entity.set_level(value)
|
||||
|
||||
def zdo_command(self, *args, **kwargs):
|
||||
"""Handle ZDO commands on this cluster."""
|
||||
pass
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize Switch."""
|
||||
super().__init__(**kwargs)
|
||||
self._state = False
|
||||
self._level = 0
|
||||
from zigpy.zcl.clusters import general
|
||||
self._out_listeners = {
|
||||
general.OnOff.cluster_id: self.OnOffListener(self),
|
||||
general.LevelControl.cluster_id: self.LevelListener(self),
|
||||
general.OnOff.cluster_id: OnOffListener(
|
||||
self,
|
||||
self._out_clusters[general.OnOff.cluster_id]
|
||||
)
|
||||
}
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Let zha handle polling."""
|
||||
return False
|
||||
out_clusters = kwargs.get('out_clusters')
|
||||
self._zcl_reporting = {}
|
||||
|
||||
if general.LevelControl.cluster_id in out_clusters:
|
||||
self._out_listeners.update({
|
||||
general.LevelControl.cluster_id: LevelListener(
|
||||
self,
|
||||
out_clusters[general.LevelControl.cluster_id]
|
||||
)
|
||||
})
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
@@ -228,6 +201,11 @@ class Remote(zha.Entity, BinarySensorDevice):
|
||||
})
|
||||
return self._device_state_attributes
|
||||
|
||||
@property
|
||||
def zcl_reporting_config(self):
|
||||
"""Return ZCL attribute reporting configuration."""
|
||||
return self._zcl_reporting
|
||||
|
||||
def move_level(self, change):
|
||||
"""Increment the level, setting state if appropriate."""
|
||||
if not self._state and change > 0:
|
||||
@@ -249,13 +227,91 @@ class Remote(zha.Entity, BinarySensorDevice):
|
||||
self._level = 255
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def async_configure(self):
|
||||
"""Bind clusters."""
|
||||
from zigpy.zcl.clusters import general
|
||||
await helpers.bind_cluster(
|
||||
self.entity_id,
|
||||
self._out_clusters[general.OnOff.cluster_id]
|
||||
)
|
||||
if general.LevelControl.cluster_id in self._out_clusters:
|
||||
await helpers.bind_cluster(
|
||||
self.entity_id,
|
||||
self._out_clusters[general.LevelControl.cluster_id]
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
old_state = await self.async_get_last_state()
|
||||
if self._state is not None or old_state is None:
|
||||
return
|
||||
|
||||
_LOGGER.debug("%s restoring old state: %s", self.entity_id, old_state)
|
||||
if 'level' in old_state.attributes:
|
||||
self._level = old_state.attributes['level']
|
||||
self._state = old_state.state == STATE_ON
|
||||
|
||||
async def async_update(self):
|
||||
"""Retrieve latest state."""
|
||||
from zigpy.zcl.clusters.general import OnOff
|
||||
result = await zha.safe_read(
|
||||
result = await helpers.safe_read(
|
||||
self._endpoint.out_clusters[OnOff.cluster_id],
|
||||
['on_off'],
|
||||
allow_cache=False,
|
||||
only_cache=(not self._initialized)
|
||||
)
|
||||
self._state = result.get('on_off', self._state)
|
||||
|
||||
|
||||
class BinarySensor(RestoreEntity, ZhaEntity, BinarySensorDevice):
|
||||
"""ZHA switch."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_device_class = None
|
||||
value_attribute = 0
|
||||
|
||||
def __init__(self, device_class, **kwargs):
|
||||
"""Initialize the ZHA binary sensor."""
|
||||
super().__init__(**kwargs)
|
||||
self._device_class = device_class
|
||||
self._cluster = list(kwargs['in_clusters'].values())[0]
|
||||
|
||||
def attribute_updated(self, attribute, value):
|
||||
"""Handle attribute update from device."""
|
||||
_LOGGER.debug("Attribute updated: %s %s %s", self, attribute, value)
|
||||
if attribute == self.value_attribute:
|
||||
self._state = bool(value)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
old_state = await self.async_get_last_state()
|
||||
if self._state is not None or old_state is None:
|
||||
return
|
||||
|
||||
_LOGGER.debug("%s restoring old state: %s", self.entity_id, old_state)
|
||||
self._state = old_state.state == STATE_ON
|
||||
|
||||
@property
|
||||
def cluster(self):
|
||||
"""Zigbee cluster for this entity."""
|
||||
return self._cluster
|
||||
|
||||
@property
|
||||
def zcl_reporting_config(self):
|
||||
"""ZHA reporting configuration."""
|
||||
return {self.cluster: {self.value_attribute: REPORT_CONFIG_IMMEDIATE}}
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if the switch is on based on the statemachine."""
|
||||
if self._state is None:
|
||||
return False
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_class(self) -> str:
|
||||
"""Return device class from component DEVICE_CLASSES."""
|
||||
return self._device_class
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.const import (
|
||||
CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME,
|
||||
CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT)
|
||||
|
||||
REQUIREMENTS = ['blinkpy==0.10.3']
|
||||
REQUIREMENTS = ['blinkpy==0.11.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,7 +111,7 @@ def setup(hass, config):
|
||||
|
||||
def trigger_camera(call):
|
||||
"""Trigger a camera."""
|
||||
cameras = hass.data[BLINK_DATA].sync.cameras
|
||||
cameras = hass.data[BLINK_DATA].cameras
|
||||
name = call.data[CONF_NAME]
|
||||
if name in cameras:
|
||||
cameras[name].snap_picture()
|
||||
@@ -148,7 +148,7 @@ async def async_handle_save_video_service(hass, call):
|
||||
|
||||
def _write_video(camera_name, video_path):
|
||||
"""Call video write."""
|
||||
all_cameras = hass.data[BLINK_DATA].sync.cameras
|
||||
all_cameras = hass.data[BLINK_DATA].cameras
|
||||
if camera_name in all_cameras:
|
||||
all_cameras[camera_name].video_to_file(video_path)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ FALLBACK_STREAM_INTERVAL = 1 # seconds
|
||||
MIN_STREAM_INTERVAL = 0.5 # seconds
|
||||
|
||||
CAMERA_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
})
|
||||
|
||||
CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
|
||||
|
||||
@@ -7,7 +7,7 @@ https://home-assistant.io/components/camera.axis/
|
||||
import logging
|
||||
|
||||
from homeassistant.components.camera.mjpeg import (
|
||||
CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera)
|
||||
CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera, filter_urllib3_logging)
|
||||
from homeassistant.const import (
|
||||
CONF_AUTHENTICATION, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT,
|
||||
CONF_USERNAME, HTTP_DIGEST_AUTHENTICATION)
|
||||
@@ -29,6 +29,8 @@ def _get_image_url(host, port, mode):
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Axis camera."""
|
||||
filter_urllib3_logging()
|
||||
|
||||
camera_config = {
|
||||
CONF_NAME: discovery_info[CONF_NAME],
|
||||
CONF_USERNAME: discovery_info[CONF_USERNAME],
|
||||
|
||||
@@ -23,7 +23,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
return
|
||||
data = hass.data[BLINK_DATA]
|
||||
devs = []
|
||||
for name, camera in data.sync.cameras.items():
|
||||
for name, camera in data.cameras.items():
|
||||
devs.append(BlinkCamera(data, name, camera))
|
||||
|
||||
add_entities(devs)
|
||||
|
||||
@@ -16,7 +16,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION,
|
||||
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
|
||||
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL)
|
||||
from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera)
|
||||
from homeassistant.helpers.aiohttp_client import (
|
||||
async_get_clientsession, async_aiohttp_proxy_web)
|
||||
@@ -29,6 +29,7 @@ CONF_STILL_IMAGE_URL = 'still_image_url'
|
||||
CONTENT_TYPE_HEADER = 'Content-Type'
|
||||
|
||||
DEFAULT_NAME = 'Mjpeg Camera'
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_MJPEG_URL): cv.url,
|
||||
@@ -38,13 +39,22 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
|
||||
})
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up a MJPEG IP Camera."""
|
||||
# Filter header errors from urllib3 due to a urllib3 bug
|
||||
filter_urllib3_logging()
|
||||
|
||||
if discovery_info:
|
||||
config = PLATFORM_SCHEMA(discovery_info)
|
||||
async_add_entities([MjpegCamera(config)])
|
||||
|
||||
|
||||
def filter_urllib3_logging():
|
||||
"""Filter header errors from urllib3 due to a urllib3 bug."""
|
||||
urllib3_logger = logging.getLogger("urllib3.connectionpool")
|
||||
if not any(isinstance(x, NoHeaderErrorFilter)
|
||||
for x in urllib3_logger.filters):
|
||||
@@ -52,21 +62,24 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
NoHeaderErrorFilter()
|
||||
)
|
||||
|
||||
if discovery_info:
|
||||
config = PLATFORM_SCHEMA(discovery_info)
|
||||
async_add_entities([MjpegCamera(config)])
|
||||
|
||||
|
||||
def extract_image_from_mjpeg(stream):
|
||||
"""Take in a MJPEG stream object, return the jpg from it."""
|
||||
data = b''
|
||||
|
||||
for chunk in stream:
|
||||
data += chunk
|
||||
jpg_start = data.find(b'\xff\xd8')
|
||||
jpg_end = data.find(b'\xff\xd9')
|
||||
if jpg_start != -1 and jpg_end != -1:
|
||||
jpg = data[jpg_start:jpg_end + 2]
|
||||
return jpg
|
||||
|
||||
if jpg_end == -1:
|
||||
continue
|
||||
|
||||
jpg_start = data.find(b'\xff\xd8')
|
||||
|
||||
if jpg_start == -1:
|
||||
continue
|
||||
|
||||
return data[jpg_start:jpg_end + 2]
|
||||
|
||||
|
||||
class MjpegCamera(Camera):
|
||||
@@ -88,6 +101,7 @@ class MjpegCamera(Camera):
|
||||
self._auth = aiohttp.BasicAuth(
|
||||
self._username, password=self._password
|
||||
)
|
||||
self._verify_ssl = device_info.get(CONF_VERIFY_SSL)
|
||||
|
||||
async def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
@@ -98,7 +112,10 @@ class MjpegCamera(Camera):
|
||||
self.camera_image)
|
||||
return image
|
||||
|
||||
websession = async_get_clientsession(self.hass)
|
||||
websession = async_get_clientsession(
|
||||
self.hass,
|
||||
verify_ssl=self._verify_ssl
|
||||
)
|
||||
try:
|
||||
with async_timeout.timeout(10, loop=self.hass.loop):
|
||||
response = await websession.get(
|
||||
@@ -121,7 +138,12 @@ class MjpegCamera(Camera):
|
||||
else:
|
||||
auth = HTTPBasicAuth(self._username, self._password)
|
||||
req = requests.get(
|
||||
self._mjpeg_url, auth=auth, stream=True, timeout=10)
|
||||
self._mjpeg_url,
|
||||
auth=auth,
|
||||
stream=True,
|
||||
timeout=10,
|
||||
verify=self._verify_ssl
|
||||
)
|
||||
else:
|
||||
req = requests.get(self._mjpeg_url, stream=True, timeout=10)
|
||||
|
||||
@@ -137,7 +159,10 @@ class MjpegCamera(Camera):
|
||||
return await super().handle_async_mjpeg_stream(request)
|
||||
|
||||
# connect to stream
|
||||
websession = async_get_clientsession(self.hass)
|
||||
websession = async_get_clientsession(
|
||||
self.hass,
|
||||
verify_ssl=self._verify_ssl
|
||||
)
|
||||
stream_coro = websession.get(self._mjpeg_url, auth=self._auth)
|
||||
|
||||
return await async_aiohttp_proxy_web(self.hass, request, stream_coro)
|
||||
|
||||
@@ -10,14 +10,15 @@ import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, HTTP_HEADER_HA_AUTH
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \
|
||||
HTTP_HEADER_HA_AUTH
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util.async_ import run_coroutine_threadsafe
|
||||
import homeassistant.util.dt as dt_util
|
||||
from . import async_get_still_stream
|
||||
from homeassistant.components.camera import async_get_still_stream
|
||||
|
||||
REQUIREMENTS = ['pillow==5.2.0']
|
||||
REQUIREMENTS = ['pillow==5.3.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,21 +27,34 @@ CONF_FORCE_RESIZE = 'force_resize'
|
||||
CONF_IMAGE_QUALITY = 'image_quality'
|
||||
CONF_IMAGE_REFRESH_RATE = 'image_refresh_rate'
|
||||
CONF_MAX_IMAGE_WIDTH = 'max_image_width'
|
||||
CONF_MAX_IMAGE_HEIGHT = 'max_image_height'
|
||||
CONF_MAX_STREAM_WIDTH = 'max_stream_width'
|
||||
CONF_MAX_STREAM_HEIGHT = 'max_stream_height'
|
||||
CONF_IMAGE_TOP = 'image_top'
|
||||
CONF_IMAGE_LEFT = 'image_left'
|
||||
CONF_STREAM_QUALITY = 'stream_quality'
|
||||
|
||||
MODE_RESIZE = 'resize'
|
||||
MODE_CROP = 'crop'
|
||||
|
||||
DEFAULT_BASENAME = "Camera Proxy"
|
||||
DEFAULT_QUALITY = 75
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean,
|
||||
vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean,
|
||||
vol.Optional(CONF_MODE, default=MODE_RESIZE):
|
||||
vol.In([MODE_RESIZE, MODE_CROP]),
|
||||
vol.Optional(CONF_IMAGE_QUALITY): int,
|
||||
vol.Optional(CONF_IMAGE_REFRESH_RATE): float,
|
||||
vol.Optional(CONF_MAX_IMAGE_WIDTH): int,
|
||||
vol.Optional(CONF_MAX_IMAGE_HEIGHT): int,
|
||||
vol.Optional(CONF_MAX_STREAM_WIDTH): int,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_MAX_STREAM_HEIGHT): int,
|
||||
vol.Optional(CONF_IMAGE_LEFT): int,
|
||||
vol.Optional(CONF_IMAGE_TOP): int,
|
||||
vol.Optional(CONF_STREAM_QUALITY): int,
|
||||
})
|
||||
|
||||
@@ -51,26 +65,37 @@ async def async_setup_platform(
|
||||
async_add_entities([ProxyCamera(hass, config)])
|
||||
|
||||
|
||||
def _precheck_image(image, opts):
|
||||
"""Perform some pre-checks on the given image."""
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
if not opts:
|
||||
raise ValueError()
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image))
|
||||
except IOError:
|
||||
_LOGGER.warning("Failed to open image")
|
||||
raise ValueError()
|
||||
imgfmt = str(img.format)
|
||||
if imgfmt not in ('PNG', 'JPEG'):
|
||||
_LOGGER.warning("Image is of unsupported type: %s", imgfmt)
|
||||
raise ValueError()
|
||||
return img
|
||||
|
||||
|
||||
def _resize_image(image, opts):
|
||||
"""Resize image."""
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
if not opts:
|
||||
try:
|
||||
img = _precheck_image(image, opts)
|
||||
except ValueError:
|
||||
return image
|
||||
|
||||
quality = opts.quality or DEFAULT_QUALITY
|
||||
new_width = opts.max_width
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image))
|
||||
except IOError:
|
||||
return image
|
||||
imgfmt = str(img.format)
|
||||
if imgfmt not in ('PNG', 'JPEG'):
|
||||
_LOGGER.debug("Image is of unsupported type: %s", imgfmt)
|
||||
return image
|
||||
|
||||
(old_width, old_height) = img.size
|
||||
old_size = len(image)
|
||||
if old_width <= new_width:
|
||||
@@ -87,7 +112,7 @@ def _resize_image(image, opts):
|
||||
img.save(imgbuf, 'JPEG', optimize=True, quality=quality)
|
||||
newimage = imgbuf.getvalue()
|
||||
if not opts.force_resize and len(newimage) >= old_size:
|
||||
_LOGGER.debug("Using original image(%d bytes) "
|
||||
_LOGGER.debug("Using original image (%d bytes) "
|
||||
"because resized image (%d bytes) is not smaller",
|
||||
old_size, len(newimage))
|
||||
return image
|
||||
@@ -98,12 +123,50 @@ def _resize_image(image, opts):
|
||||
return newimage
|
||||
|
||||
|
||||
def _crop_image(image, opts):
|
||||
"""Crop image."""
|
||||
import io
|
||||
|
||||
try:
|
||||
img = _precheck_image(image, opts)
|
||||
except ValueError:
|
||||
return image
|
||||
|
||||
quality = opts.quality or DEFAULT_QUALITY
|
||||
(old_width, old_height) = img.size
|
||||
old_size = len(image)
|
||||
if opts.top is None:
|
||||
opts.top = 0
|
||||
if opts.left is None:
|
||||
opts.left = 0
|
||||
if opts.max_width is None or opts.max_width > old_width - opts.left:
|
||||
opts.max_width = old_width - opts.left
|
||||
if opts.max_height is None or opts.max_height > old_height - opts.top:
|
||||
opts.max_height = old_height - opts.top
|
||||
|
||||
img = img.crop((opts.left, opts.top,
|
||||
opts.left+opts.max_width, opts.top+opts.max_height))
|
||||
imgbuf = io.BytesIO()
|
||||
img.save(imgbuf, 'JPEG', optimize=True, quality=quality)
|
||||
newimage = imgbuf.getvalue()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Cropped image from (%dx%d - %d bytes) to (%dx%d - %d bytes)",
|
||||
old_width, old_height, old_size, opts.max_width, opts.max_height,
|
||||
len(newimage))
|
||||
return newimage
|
||||
|
||||
|
||||
class ImageOpts():
|
||||
"""The representation of image options."""
|
||||
|
||||
def __init__(self, max_width, quality, force_resize):
|
||||
def __init__(self, max_width, max_height, left, top,
|
||||
quality, force_resize):
|
||||
"""Initialize image options."""
|
||||
self.max_width = max_width
|
||||
self.max_height = max_height
|
||||
self.left = left
|
||||
self.top = top
|
||||
self.quality = quality
|
||||
self.force_resize = force_resize
|
||||
|
||||
@@ -125,11 +188,18 @@ class ProxyCamera(Camera):
|
||||
"{} - {}".format(DEFAULT_BASENAME, self._proxied_camera))
|
||||
self._image_opts = ImageOpts(
|
||||
config.get(CONF_MAX_IMAGE_WIDTH),
|
||||
config.get(CONF_MAX_IMAGE_HEIGHT),
|
||||
config.get(CONF_IMAGE_LEFT),
|
||||
config.get(CONF_IMAGE_TOP),
|
||||
config.get(CONF_IMAGE_QUALITY),
|
||||
config.get(CONF_FORCE_RESIZE))
|
||||
|
||||
self._stream_opts = ImageOpts(
|
||||
config.get(CONF_MAX_STREAM_WIDTH), config.get(CONF_STREAM_QUALITY),
|
||||
config.get(CONF_MAX_STREAM_WIDTH),
|
||||
config.get(CONF_MAX_STREAM_HEIGHT),
|
||||
config.get(CONF_IMAGE_LEFT),
|
||||
config.get(CONF_IMAGE_TOP),
|
||||
config.get(CONF_STREAM_QUALITY),
|
||||
True)
|
||||
|
||||
self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE)
|
||||
@@ -141,6 +211,7 @@ class ProxyCamera(Camera):
|
||||
self._headers = (
|
||||
{HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password}
|
||||
if self.hass.config.api.api_password is not None else None)
|
||||
self._mode = config.get(CONF_MODE)
|
||||
|
||||
def camera_image(self):
|
||||
"""Return camera image."""
|
||||
@@ -162,8 +233,12 @@ class ProxyCamera(Camera):
|
||||
_LOGGER.error("Error getting original camera image")
|
||||
return self._last_image
|
||||
|
||||
image = await self.hass.async_add_job(
|
||||
_resize_image, image.content, self._image_opts)
|
||||
if self._mode == MODE_RESIZE:
|
||||
job = _resize_image
|
||||
else:
|
||||
job = _crop_image
|
||||
image = await self.hass.async_add_executor_job(
|
||||
job, image.content, self._image_opts)
|
||||
|
||||
if self._cache_images:
|
||||
self._last_image = image
|
||||
@@ -192,7 +267,11 @@ class ProxyCamera(Camera):
|
||||
if not image:
|
||||
return None
|
||||
except HomeAssistantError:
|
||||
raise asyncio.CancelledError
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
return await self.hass.async_add_job(
|
||||
_resize_image, image.content, self._stream_opts)
|
||||
if self._mode == MODE_RESIZE:
|
||||
job = _resize_image
|
||||
else:
|
||||
job = _crop_image
|
||||
return await self.hass.async_add_executor_job(
|
||||
job, image.content, self._stream_opts)
|
||||
|
||||
@@ -5,35 +5,33 @@ For more details about this platform, please refer to the documentation
|
||||
https://home-assistant.io/components/camera.push/
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from collections import deque
|
||||
from datetime import timedelta
|
||||
import voluptuous as vol
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\
|
||||
STATE_IDLE, STATE_RECORDING
|
||||
STATE_IDLE, STATE_RECORDING, DOMAIN
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.http.view import KEY_AUTHENTICATED,\
|
||||
HomeAssistantView
|
||||
from homeassistant.const import CONF_NAME, CONF_TIMEOUT,\
|
||||
HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST
|
||||
from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
DEPENDENCIES = ['webhook']
|
||||
|
||||
DEPENDENCIES = ['http']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_BUFFER_SIZE = 'buffer'
|
||||
CONF_IMAGE_FIELD = 'field'
|
||||
CONF_TOKEN = 'token'
|
||||
|
||||
DEFAULT_NAME = "Push Camera"
|
||||
|
||||
ATTR_FILENAME = 'filename'
|
||||
ATTR_LAST_TRIP = 'last_trip'
|
||||
ATTR_TOKEN = 'token'
|
||||
|
||||
PUSH_CAMERA_DATA = 'push_camera'
|
||||
|
||||
@@ -43,7 +41,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All(
|
||||
cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string,
|
||||
vol.Optional(CONF_TOKEN): vol.All(cv.string, vol.Length(min=8)),
|
||||
vol.Required(CONF_WEBHOOK_ID): cv.string,
|
||||
})
|
||||
|
||||
|
||||
@@ -53,69 +51,43 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
if PUSH_CAMERA_DATA not in hass.data:
|
||||
hass.data[PUSH_CAMERA_DATA] = {}
|
||||
|
||||
cameras = [PushCamera(config[CONF_NAME],
|
||||
webhook_id = config.get(CONF_WEBHOOK_ID)
|
||||
|
||||
cameras = [PushCamera(hass,
|
||||
config[CONF_NAME],
|
||||
config[CONF_BUFFER_SIZE],
|
||||
config[CONF_TIMEOUT],
|
||||
config.get(CONF_TOKEN))]
|
||||
|
||||
hass.http.register_view(CameraPushReceiver(hass,
|
||||
config[CONF_IMAGE_FIELD]))
|
||||
config[CONF_IMAGE_FIELD],
|
||||
webhook_id)]
|
||||
|
||||
async_add_entities(cameras)
|
||||
|
||||
|
||||
class CameraPushReceiver(HomeAssistantView):
|
||||
"""Handle pushes from remote camera."""
|
||||
async def handle_webhook(hass, webhook_id, request):
|
||||
"""Handle incoming webhook POST with image files."""
|
||||
try:
|
||||
with async_timeout.timeout(5, loop=hass.loop):
|
||||
data = dict(await request.post())
|
||||
except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error:
|
||||
_LOGGER.error("Could not get information from POST <%s>", error)
|
||||
return
|
||||
|
||||
url = "/api/camera_push/{entity_id}"
|
||||
name = 'api:camera_push:camera_entity'
|
||||
requires_auth = False
|
||||
camera = hass.data[PUSH_CAMERA_DATA][webhook_id]
|
||||
|
||||
def __init__(self, hass, image_field):
|
||||
"""Initialize CameraPushReceiver with camera entity."""
|
||||
self._cameras = hass.data[PUSH_CAMERA_DATA]
|
||||
self._image = image_field
|
||||
if camera.image_field not in data:
|
||||
_LOGGER.warning("Webhook call without POST parameter <%s>",
|
||||
camera.image_field)
|
||||
return
|
||||
|
||||
async def post(self, request, entity_id):
|
||||
"""Accept the POST from Camera."""
|
||||
_camera = self._cameras.get(entity_id)
|
||||
|
||||
if _camera is None:
|
||||
_LOGGER.error("Unknown %s", entity_id)
|
||||
status = HTTP_NOT_FOUND if request[KEY_AUTHENTICATED]\
|
||||
else HTTP_UNAUTHORIZED
|
||||
return self.json_message('Unknown {}'.format(entity_id),
|
||||
status)
|
||||
|
||||
# Supports HA authentication and token based
|
||||
# when token has been configured
|
||||
authenticated = (request[KEY_AUTHENTICATED] or
|
||||
(_camera.token is not None and
|
||||
request.query.get('token') == _camera.token))
|
||||
|
||||
if not authenticated:
|
||||
return self.json_message(
|
||||
'Invalid authorization credentials for {}'.format(entity_id),
|
||||
HTTP_UNAUTHORIZED)
|
||||
|
||||
try:
|
||||
data = await request.post()
|
||||
_LOGGER.debug("Received Camera push: %s", data[self._image])
|
||||
await _camera.update_image(data[self._image].file.read(),
|
||||
data[self._image].filename)
|
||||
except ValueError as value_error:
|
||||
_LOGGER.error("Unknown value %s", value_error)
|
||||
return self.json_message('Invalid POST', HTTP_BAD_REQUEST)
|
||||
except KeyError as key_error:
|
||||
_LOGGER.error('In your POST message %s', key_error)
|
||||
return self.json_message('{} missing'.format(self._image),
|
||||
HTTP_BAD_REQUEST)
|
||||
await camera.update_image(data[camera.image_field].file.read(),
|
||||
data[camera.image_field].filename)
|
||||
|
||||
|
||||
class PushCamera(Camera):
|
||||
"""The representation of a Push camera."""
|
||||
|
||||
def __init__(self, name, buffer_size, timeout, token):
|
||||
def __init__(self, hass, name, buffer_size, timeout, image_field,
|
||||
webhook_id):
|
||||
"""Initialize push camera component."""
|
||||
super().__init__()
|
||||
self._name = name
|
||||
@@ -126,11 +98,28 @@ class PushCamera(Camera):
|
||||
self._timeout = timeout
|
||||
self.queue = deque([], buffer_size)
|
||||
self._current_image = None
|
||||
self.token = token
|
||||
self._image_field = image_field
|
||||
self.webhook_id = webhook_id
|
||||
self.webhook_url = \
|
||||
hass.components.webhook.async_generate_url(webhook_id)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Call when entity is added to hass."""
|
||||
self.hass.data[PUSH_CAMERA_DATA][self.entity_id] = self
|
||||
self.hass.data[PUSH_CAMERA_DATA][self.webhook_id] = self
|
||||
|
||||
try:
|
||||
self.hass.components.webhook.async_register(DOMAIN,
|
||||
self.name,
|
||||
self.webhook_id,
|
||||
handle_webhook)
|
||||
except ValueError:
|
||||
_LOGGER.error("In <%s>, webhook_id <%s> already used",
|
||||
self.name, self.webhook_id)
|
||||
|
||||
@property
|
||||
def image_field(self):
|
||||
"""HTTP field containing the image file."""
|
||||
return self._image_field
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
@@ -189,6 +178,5 @@ class PushCamera(Camera):
|
||||
name: value for name, value in (
|
||||
(ATTR_LAST_TRIP, self._last_trip),
|
||||
(ATTR_FILENAME, self._filename),
|
||||
(ATTR_TOKEN, self.token),
|
||||
) if value is not None
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import PLATFORM_SCHEMA
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.components.skybell import (
|
||||
@@ -19,14 +24,33 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=90)
|
||||
|
||||
IMAGE_AVATAR = 'avatar'
|
||||
IMAGE_ACTIVITY = 'activity'
|
||||
|
||||
CONF_ACTIVITY_NAME = 'activity_name'
|
||||
CONF_AVATAR_NAME = 'avatar_name'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_MONITORED_CONDITIONS, default=[IMAGE_AVATAR]):
|
||||
vol.All(cv.ensure_list, [vol.In([IMAGE_AVATAR, IMAGE_ACTIVITY])]),
|
||||
vol.Optional(CONF_ACTIVITY_NAME): cv.string,
|
||||
vol.Optional(CONF_AVATAR_NAME): cv.string,
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the platform for a Skybell device."""
|
||||
cond = config[CONF_MONITORED_CONDITIONS]
|
||||
names = {}
|
||||
names[IMAGE_ACTIVITY] = config.get(CONF_ACTIVITY_NAME)
|
||||
names[IMAGE_AVATAR] = config.get(CONF_AVATAR_NAME)
|
||||
skybell = hass.data.get(SKYBELL_DOMAIN)
|
||||
|
||||
sensors = []
|
||||
for device in skybell.get_devices():
|
||||
sensors.append(SkybellCamera(device))
|
||||
for camera_type in cond:
|
||||
sensors.append(SkybellCamera(device, camera_type,
|
||||
names.get(camera_type)))
|
||||
|
||||
add_entities(sensors, True)
|
||||
|
||||
@@ -34,11 +58,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
class SkybellCamera(SkybellDevice, Camera):
|
||||
"""A camera implementation for Skybell devices."""
|
||||
|
||||
def __init__(self, device):
|
||||
def __init__(self, device, camera_type, name=None):
|
||||
"""Initialize a camera for a Skybell device."""
|
||||
self._type = camera_type
|
||||
SkybellDevice.__init__(self, device)
|
||||
Camera.__init__(self)
|
||||
self._name = self._device.name
|
||||
if name is not None:
|
||||
self._name = "{} {}".format(self._device.name, name)
|
||||
else:
|
||||
self._name = self._device.name
|
||||
self._url = None
|
||||
self._response = None
|
||||
|
||||
@@ -47,12 +75,19 @@ class SkybellCamera(SkybellDevice, Camera):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def image_url(self):
|
||||
"""Get the camera image url based on type."""
|
||||
if self._type == IMAGE_ACTIVITY:
|
||||
return self._device.activity_image
|
||||
return self._device.image
|
||||
|
||||
def camera_image(self):
|
||||
"""Get the latest camera image."""
|
||||
super().update()
|
||||
|
||||
if self._url != self._device.image:
|
||||
self._url = self._device.image
|
||||
if self._url != self.image_url:
|
||||
self._url = self.image_url
|
||||
|
||||
try:
|
||||
self._response = requests.get(
|
||||
|
||||
@@ -10,12 +10,12 @@ import socket
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_PORT
|
||||
from homeassistant.const import CONF_PORT, CONF_SSL
|
||||
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
|
||||
REQUIREMENTS = ['uvcclient==0.10.1']
|
||||
REQUIREMENTS = ['uvcclient==0.11.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,12 +25,14 @@ CONF_PASSWORD = 'password'
|
||||
|
||||
DEFAULT_PASSWORD = 'ubnt'
|
||||
DEFAULT_PORT = 7080
|
||||
DEFAULT_SSL = False
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_NVR): cv.string,
|
||||
vol.Required(CONF_KEY): cv.string,
|
||||
vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
|
||||
})
|
||||
|
||||
|
||||
@@ -40,11 +42,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
key = config[CONF_KEY]
|
||||
password = config[CONF_PASSWORD]
|
||||
port = config[CONF_PORT]
|
||||
ssl = config[CONF_SSL]
|
||||
|
||||
from uvcclient import nvr
|
||||
try:
|
||||
# Exceptions may be raised in all method calls to the nvr library.
|
||||
nvrconn = nvr.UVCRemote(addr, port, key)
|
||||
nvrconn = nvr.UVCRemote(addr, port, key, ssl=ssl)
|
||||
cameras = nvrconn.index()
|
||||
|
||||
identifier = 'id' if nvrconn.server_version >= (3, 2, 0) else 'uuid'
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
|
||||
REQUIREMENTS = ['aioftp==0.10.1']
|
||||
REQUIREMENTS = ['aioftp==0.12.0']
|
||||
DEPENDENCIES = ['ffmpeg']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ https://home-assistant.io/components/camera.zoneminder/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_NAME, CONF_VERIFY_SSL
|
||||
from homeassistant.components.camera.mjpeg import (
|
||||
CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera)
|
||||
CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera, filter_urllib3_logging)
|
||||
from homeassistant.components.zoneminder import DOMAIN as ZONEMINDER_DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -18,6 +18,7 @@ DEPENDENCIES = ['zoneminder']
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the ZoneMinder cameras."""
|
||||
filter_urllib3_logging()
|
||||
zm_client = hass.data[ZONEMINDER_DOMAIN]
|
||||
|
||||
monitors = zm_client.get_monitors()
|
||||
@@ -28,22 +29,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
cameras = []
|
||||
for monitor in monitors:
|
||||
_LOGGER.info("Initializing camera %s", monitor.id)
|
||||
cameras.append(ZoneMinderCamera(monitor))
|
||||
cameras.append(ZoneMinderCamera(monitor, zm_client.verify_ssl))
|
||||
add_entities(cameras)
|
||||
|
||||
|
||||
class ZoneMinderCamera(MjpegCamera):
|
||||
"""Representation of a ZoneMinder Monitor Stream."""
|
||||
|
||||
def __init__(self, monitor):
|
||||
def __init__(self, monitor, verify_ssl):
|
||||
"""Initialize as a subclass of MjpegCamera."""
|
||||
device_info = {
|
||||
CONF_NAME: monitor.name,
|
||||
CONF_MJPEG_URL: monitor.mjpeg_image_url,
|
||||
CONF_STILL_IMAGE_URL: monitor.still_image_url
|
||||
CONF_STILL_IMAGE_URL: monitor.still_image_url,
|
||||
CONF_VERIFY_SSL: verify_ssl
|
||||
}
|
||||
super().__init__(device_info)
|
||||
self._is_recording = None
|
||||
self._is_available = None
|
||||
self._monitor = monitor
|
||||
|
||||
@property
|
||||
@@ -55,8 +58,14 @@ class ZoneMinderCamera(MjpegCamera):
|
||||
"""Update our recording state from the ZM API."""
|
||||
_LOGGER.debug("Updating camera state for monitor %i", self._monitor.id)
|
||||
self._is_recording = self._monitor.is_recording
|
||||
self._is_available = self._monitor.is_available
|
||||
|
||||
@property
|
||||
def is_recording(self):
|
||||
"""Return whether the monitor is in alarm mode."""
|
||||
return self._is_recording
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return self._is_available
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Voleu configurar Google Cast?",
|
||||
"description": "Vols configurar Google Cast?",
|
||||
"title": "Google Cast"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton."
|
||||
"no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.",
|
||||
"single_instance_allowed": "Csak egyetlen Google Cast konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
|
||||
@@ -92,15 +92,15 @@ CONVERTIBLE_ATTRIBUTE = [
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ON_OFF_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
})
|
||||
|
||||
SET_AWAY_MODE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Required(ATTR_AWAY_MODE): cv.boolean,
|
||||
})
|
||||
SET_AUX_HEAT_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Required(ATTR_AUX_HEAT): cv.boolean,
|
||||
})
|
||||
SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All(
|
||||
@@ -110,28 +110,28 @@ SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All(
|
||||
vol.Exclusive(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float),
|
||||
vol.Inclusive(ATTR_TARGET_TEMP_HIGH, 'temperature'): vol.Coerce(float),
|
||||
vol.Inclusive(ATTR_TARGET_TEMP_LOW, 'temperature'): vol.Coerce(float),
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Optional(ATTR_OPERATION_MODE): cv.string,
|
||||
}
|
||||
))
|
||||
SET_FAN_MODE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Required(ATTR_FAN_MODE): cv.string,
|
||||
})
|
||||
SET_HOLD_MODE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Required(ATTR_HOLD_MODE): cv.string,
|
||||
})
|
||||
SET_OPERATION_MODE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Required(ATTR_OPERATION_MODE): cv.string,
|
||||
})
|
||||
SET_HUMIDITY_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Required(ATTR_HUMIDITY): vol.Coerce(float),
|
||||
})
|
||||
SET_SWING_MODE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
|
||||
vol.Required(ATTR_SWING_MODE): cv.string,
|
||||
})
|
||||
|
||||
|
||||
@@ -15,14 +15,13 @@ from homeassistant.components.climate import (
|
||||
STATE_FAN_ONLY, STATE_HEAT, STATE_OFF, SUPPORT_FAN_MODE,
|
||||
SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE,
|
||||
ClimateDevice)
|
||||
from homeassistant.components.daikin import (
|
||||
ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_TEMPERATURE,
|
||||
daikin_api_setup)
|
||||
from homeassistant.components.daikin import DOMAIN as DAIKIN_DOMAIN
|
||||
from homeassistant.components.daikin.const import (
|
||||
ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_TEMPERATURE)
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pydaikin==0.8']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,18 +59,18 @@ HA_ATTR_TO_DAIKIN = {
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Daikin HVAC platform."""
|
||||
if discovery_info is not None:
|
||||
host = discovery_info.get('ip')
|
||||
name = None
|
||||
_LOGGER.debug("Discovered a Daikin AC on %s", host)
|
||||
else:
|
||||
host = config.get(CONF_HOST)
|
||||
name = config.get(CONF_NAME)
|
||||
_LOGGER.debug("Added Daikin AC on %s", host)
|
||||
"""Old way of setting up the Daikin HVAC platform.
|
||||
|
||||
api = daikin_api_setup(hass, host, name)
|
||||
add_entities([DaikinClimate(api)], True)
|
||||
Can only be called when a user accidentally mentions the platform in their
|
||||
config. But even in that case it would have been ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up Daikin climate based on config_entry."""
|
||||
daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id)
|
||||
async_add_entities([DaikinClimate(daikin_api)])
|
||||
|
||||
|
||||
class DaikinClimate(ClimateDevice):
|
||||
@@ -192,6 +191,11 @@ class DaikinClimate(ClimateDevice):
|
||||
"""Return the name of the thermostat, if any."""
|
||||
return self._api.name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._api.mac
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement which this thermostat uses."""
|
||||
@@ -261,3 +265,8 @@ class DaikinClimate(ClimateDevice):
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
self._api.update()
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return a device description for device registry."""
|
||||
return self._api.device_info
|
||||
|
||||
@@ -9,7 +9,8 @@ import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
STATE_ON, STATE_OFF, STATE_AUTO, PLATFORM_SCHEMA, ClimateDevice,
|
||||
STATE_ON, STATE_OFF, STATE_HEAT, STATE_MANUAL, STATE_ECO, PLATFORM_SCHEMA,
|
||||
ClimateDevice,
|
||||
SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE,
|
||||
SUPPORT_ON_OFF)
|
||||
from homeassistant.const import (
|
||||
@@ -21,8 +22,6 @@ REQUIREMENTS = ['python-eq3bt==0.1.9', 'construct==2.9.45']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STATE_BOOST = 'boost'
|
||||
STATE_AWAY = 'away'
|
||||
STATE_MANUAL = 'manual'
|
||||
|
||||
ATTR_STATE_WINDOW_OPEN = 'window_open'
|
||||
ATTR_STATE_VALVE = 'valve'
|
||||
@@ -65,10 +64,10 @@ class EQ3BTSmartThermostat(ClimateDevice):
|
||||
self.modes = {
|
||||
eq3.Mode.Open: STATE_ON,
|
||||
eq3.Mode.Closed: STATE_OFF,
|
||||
eq3.Mode.Auto: STATE_AUTO,
|
||||
eq3.Mode.Auto: STATE_HEAT,
|
||||
eq3.Mode.Manual: STATE_MANUAL,
|
||||
eq3.Mode.Boost: STATE_BOOST,
|
||||
eq3.Mode.Away: STATE_AWAY,
|
||||
eq3.Mode.Away: STATE_ECO,
|
||||
}
|
||||
|
||||
self.reverse_modes = {v: k for k, v in self.modes.items()}
|
||||
@@ -140,20 +139,20 @@ class EQ3BTSmartThermostat(ClimateDevice):
|
||||
|
||||
def turn_away_mode_off(self):
|
||||
"""Away mode off turns to AUTO mode."""
|
||||
self.set_operation_mode(STATE_AUTO)
|
||||
self.set_operation_mode(STATE_HEAT)
|
||||
|
||||
def turn_away_mode_on(self):
|
||||
"""Set away mode on."""
|
||||
self.set_operation_mode(STATE_AWAY)
|
||||
self.set_operation_mode(STATE_ECO)
|
||||
|
||||
@property
|
||||
def is_away_mode_on(self):
|
||||
"""Return if we are away."""
|
||||
return self.current_operation == STATE_AWAY
|
||||
return self.current_operation == STATE_ECO
|
||||
|
||||
def turn_on(self):
|
||||
"""Turn device on."""
|
||||
self.set_operation_mode(STATE_AUTO)
|
||||
self.set_operation_mode(STATE_HEAT)
|
||||
|
||||
def turn_off(self):
|
||||
"""Turn device off."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for Honeywell evohome (EMEA/EU-based systems only).
|
||||
"""Support for Climate devices of (EMEA/EU-based) Honeywell evohome systems.
|
||||
|
||||
Support for a temperature control system (TCS, controller) with 0+ heating
|
||||
zones (e.g. TRVs, relays) and, optionally, a DHW controller.
|
||||
zones (e.g. TRVs, relays).
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.evohome/
|
||||
@@ -13,29 +13,34 @@ import logging
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice,
|
||||
STATE_AUTO,
|
||||
STATE_ECO,
|
||||
STATE_OFF,
|
||||
SUPPORT_OPERATION_MODE,
|
||||
STATE_AUTO, STATE_ECO, STATE_MANUAL, STATE_OFF,
|
||||
SUPPORT_AWAY_MODE,
|
||||
SUPPORT_ON_OFF,
|
||||
SUPPORT_OPERATION_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
ClimateDevice
|
||||
)
|
||||
from homeassistant.components.evohome import (
|
||||
CONF_LOCATION_IDX,
|
||||
DATA_EVOHOME,
|
||||
MAX_TEMP,
|
||||
MIN_TEMP,
|
||||
SCAN_INTERVAL_MAX
|
||||
DATA_EVOHOME, DISPATCHER_EVOHOME,
|
||||
CONF_LOCATION_IDX, SCAN_INTERVAL_DEFAULT,
|
||||
EVO_PARENT, EVO_CHILD,
|
||||
GWS, TCS,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
PRECISION_TENTHS,
|
||||
TEMP_CELSIUS,
|
||||
HTTP_TOO_MANY_REQUESTS,
|
||||
PRECISION_HALVES,
|
||||
TEMP_CELSIUS
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
dispatcher_send,
|
||||
async_dispatcher_connect
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# these are for the controller's opmode/state and the zone's state
|
||||
# the Controller's opmode/state and the zone's (inherited) state
|
||||
EVO_RESET = 'AutoWithReset'
|
||||
EVO_AUTO = 'Auto'
|
||||
EVO_AUTOECO = 'AutoWithEco'
|
||||
@@ -44,7 +49,14 @@ EVO_DAYOFF = 'DayOff'
|
||||
EVO_CUSTOM = 'Custom'
|
||||
EVO_HEATOFF = 'HeatingOff'
|
||||
|
||||
EVO_STATE_TO_HA = {
|
||||
# these are for Zones' opmode, and state
|
||||
EVO_FOLLOW = 'FollowSchedule'
|
||||
EVO_TEMPOVER = 'TemporaryOverride'
|
||||
EVO_PERMOVER = 'PermanentOverride'
|
||||
|
||||
# for the Controller. NB: evohome treats Away mode as a mode in/of itself,
|
||||
# where HA considers it to 'override' the exising operating mode
|
||||
TCS_STATE_TO_HA = {
|
||||
EVO_RESET: STATE_AUTO,
|
||||
EVO_AUTO: STATE_AUTO,
|
||||
EVO_AUTOECO: STATE_ECO,
|
||||
@@ -53,171 +65,150 @@ EVO_STATE_TO_HA = {
|
||||
EVO_CUSTOM: STATE_AUTO,
|
||||
EVO_HEATOFF: STATE_OFF
|
||||
}
|
||||
|
||||
HA_STATE_TO_EVO = {
|
||||
HA_STATE_TO_TCS = {
|
||||
STATE_AUTO: EVO_AUTO,
|
||||
STATE_ECO: EVO_AUTOECO,
|
||||
STATE_OFF: EVO_HEATOFF
|
||||
}
|
||||
TCS_OP_LIST = list(HA_STATE_TO_TCS)
|
||||
|
||||
HA_OP_LIST = list(HA_STATE_TO_EVO)
|
||||
# the Zones' opmode; their state is usually 'inherited' from the TCS
|
||||
EVO_FOLLOW = 'FollowSchedule'
|
||||
EVO_TEMPOVER = 'TemporaryOverride'
|
||||
EVO_PERMOVER = 'PermanentOverride'
|
||||
|
||||
# these are used to help prevent E501 (line too long) violations
|
||||
GWS = 'gateways'
|
||||
TCS = 'temperatureControlSystems'
|
||||
|
||||
# debug codes - these happen occasionally, but the cause is unknown
|
||||
EVO_DEBUG_NO_RECENT_UPDATES = '0x01'
|
||||
EVO_DEBUG_NO_STATUS = '0x02'
|
||||
# for the Zones...
|
||||
ZONE_STATE_TO_HA = {
|
||||
EVO_FOLLOW: STATE_AUTO,
|
||||
EVO_TEMPOVER: STATE_MANUAL,
|
||||
EVO_PERMOVER: STATE_MANUAL
|
||||
}
|
||||
HA_STATE_TO_ZONE = {
|
||||
STATE_AUTO: EVO_FOLLOW,
|
||||
STATE_MANUAL: EVO_PERMOVER
|
||||
}
|
||||
ZONE_OP_LIST = list(HA_STATE_TO_ZONE)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Create a Honeywell (EMEA/EU) evohome CH/DHW system.
|
||||
|
||||
An evohome system consists of: a controller, with 0-12 heating zones (e.g.
|
||||
TRVs, relays) and, optionally, a DHW controller (a HW boiler).
|
||||
|
||||
Here, we add the controller only.
|
||||
"""
|
||||
async def async_setup_platform(hass, hass_config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Create the evohome Controller, and its Zones, if any."""
|
||||
evo_data = hass.data[DATA_EVOHOME]
|
||||
|
||||
client = evo_data['client']
|
||||
loc_idx = evo_data['params'][CONF_LOCATION_IDX]
|
||||
|
||||
# evohomeclient has no defined way of accessing non-default location other
|
||||
# than using a protected member, such as below
|
||||
# evohomeclient has exposed no means of accessing non-default location
|
||||
# (i.e. loc_idx > 0) other than using a protected member, such as below
|
||||
tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa E501; pylint: disable=protected-access
|
||||
|
||||
_LOGGER.debug(
|
||||
"setup_platform(): Found Controller: id: %s [%s], type: %s",
|
||||
"setup_platform(): Found Controller, id=%s [%s], "
|
||||
"name=%s (location_idx=%s)",
|
||||
tcs_obj_ref.systemId,
|
||||
tcs_obj_ref.modelType,
|
||||
tcs_obj_ref.location.name,
|
||||
tcs_obj_ref.modelType
|
||||
loc_idx
|
||||
)
|
||||
parent = EvoController(evo_data, client, tcs_obj_ref)
|
||||
add_entities([parent], update_before_add=True)
|
||||
|
||||
controller = EvoController(evo_data, client, tcs_obj_ref)
|
||||
zones = []
|
||||
|
||||
for zone_idx in tcs_obj_ref.zones:
|
||||
zone_obj_ref = tcs_obj_ref.zones[zone_idx]
|
||||
_LOGGER.debug(
|
||||
"setup_platform(): Found Zone, id=%s [%s], "
|
||||
"name=%s",
|
||||
zone_obj_ref.zoneId,
|
||||
zone_obj_ref.zone_type,
|
||||
zone_obj_ref.name
|
||||
)
|
||||
zones.append(EvoZone(evo_data, client, zone_obj_ref))
|
||||
|
||||
entities = [controller] + zones
|
||||
|
||||
async_add_entities(entities, update_before_add=False)
|
||||
|
||||
|
||||
class EvoController(ClimateDevice):
|
||||
"""Base for a Honeywell evohome hub/Controller device.
|
||||
class EvoClimateDevice(ClimateDevice):
|
||||
"""Base for a Honeywell evohome Climate device."""
|
||||
|
||||
The Controller (aka TCS, temperature control system) is the parent of all
|
||||
the child (CH/DHW) devices.
|
||||
"""
|
||||
# pylint: disable=no-member
|
||||
|
||||
def __init__(self, evo_data, client, obj_ref):
|
||||
"""Initialize the evohome entity.
|
||||
|
||||
Most read-only properties are set here. So are pseudo read-only,
|
||||
for example name (which _could_ change between update()s).
|
||||
"""
|
||||
self.client = client
|
||||
"""Initialize the evohome entity."""
|
||||
self._client = client
|
||||
self._obj = obj_ref
|
||||
|
||||
self._id = obj_ref.systemId
|
||||
self._name = evo_data['config']['locationInfo']['name']
|
||||
|
||||
self._config = evo_data['config'][GWS][0][TCS][0]
|
||||
self._params = evo_data['params']
|
||||
self._timers = evo_data['timers']
|
||||
|
||||
self._timers['statusUpdated'] = datetime.min
|
||||
self._status = {}
|
||||
|
||||
self._available = False # should become True after first update()
|
||||
|
||||
def _handle_requests_exceptions(self, err):
|
||||
# evohomeclient v2 api (>=0.2.7) exposes requests exceptions, incl.:
|
||||
# - HTTP_BAD_REQUEST, is usually Bad user credentials
|
||||
# - HTTP_TOO_MANY_REQUESTS, is api usuage limit exceeded
|
||||
# - HTTP_SERVICE_UNAVAILABLE, is often Vendor's fault
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect)
|
||||
|
||||
@callback
|
||||
def _connect(self, packet):
|
||||
if packet['to'] & self._type and packet['signal'] == 'refresh':
|
||||
self.async_schedule_update_ha_state(force_refresh=True)
|
||||
|
||||
def _handle_requests_exceptions(self, err):
|
||||
if err.response.status_code == HTTP_TOO_MANY_REQUESTS:
|
||||
# execute a back off: pause, and reduce rate
|
||||
old_scan_interval = self._params[CONF_SCAN_INTERVAL]
|
||||
new_scan_interval = min(old_scan_interval * 2, SCAN_INTERVAL_MAX)
|
||||
self._params[CONF_SCAN_INTERVAL] = new_scan_interval
|
||||
# execute a backoff: pause, and also reduce rate
|
||||
old_interval = self._params[CONF_SCAN_INTERVAL]
|
||||
new_interval = min(old_interval, SCAN_INTERVAL_DEFAULT) * 2
|
||||
self._params[CONF_SCAN_INTERVAL] = new_interval
|
||||
|
||||
_LOGGER.warning(
|
||||
"API rate limit has been exceeded: increasing '%s' from %s to "
|
||||
"%s seconds, and suspending polling for %s seconds.",
|
||||
"API rate limit has been exceeded. Suspending polling for %s "
|
||||
"seconds, and increasing '%s' from %s to %s seconds.",
|
||||
new_interval * 3,
|
||||
CONF_SCAN_INTERVAL,
|
||||
old_scan_interval,
|
||||
new_scan_interval,
|
||||
new_scan_interval * 3
|
||||
old_interval,
|
||||
new_interval,
|
||||
)
|
||||
|
||||
self._timers['statusUpdated'] = datetime.now() + \
|
||||
timedelta(seconds=new_scan_interval * 3)
|
||||
self._timers['statusUpdated'] = datetime.now() + new_interval * 3
|
||||
|
||||
else:
|
||||
raise err
|
||||
raise err # we dont handle any other HTTPErrors
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
def name(self) -> str:
|
||||
"""Return the name to use in the frontend UI."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if the device is available.
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend UI."""
|
||||
return self._icon
|
||||
|
||||
All evohome entities are initially unavailable. Once HA has started,
|
||||
state data is then retrieved by the Controller, and then the children
|
||||
will get a state (e.g. operating_mode, current_temperature).
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device state attributes of the evohome Climate device.
|
||||
|
||||
However, evohome entities can become unavailable for other reasons.
|
||||
This is state data that is not available otherwise, due to the
|
||||
restrictions placed upon ClimateDevice properties, etc. by HA.
|
||||
"""
|
||||
return {'status': self._status}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the device is currently available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Get the list of supported features of the Controller."""
|
||||
return SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device state attributes of the controller.
|
||||
|
||||
This is operating mode state data that is not available otherwise, due
|
||||
to the restrictions placed upon ClimateDevice properties, etc by HA.
|
||||
"""
|
||||
data = {}
|
||||
data['systemMode'] = self._status['systemModeStatus']['mode']
|
||||
data['isPermanent'] = self._status['systemModeStatus']['isPermanent']
|
||||
if 'timeUntil' in self._status['systemModeStatus']:
|
||||
data['timeUntil'] = self._status['systemModeStatus']['timeUntil']
|
||||
data['activeFaults'] = self._status['activeFaults']
|
||||
return data
|
||||
"""Get the list of supported features of the device."""
|
||||
return self._supported_features
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""Return the list of available operations."""
|
||||
return HA_OP_LIST
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return the operation mode of the evohome entity."""
|
||||
return EVO_STATE_TO_HA.get(self._status['systemModeStatus']['mode'])
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the average target temperature of the Heating/DHW zones."""
|
||||
temps = [zone['setpointStatus']['targetHeatTemperature']
|
||||
for zone in self._status['zones']]
|
||||
|
||||
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
|
||||
return avg_temp
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the average current temperature of the Heating/DHW zones."""
|
||||
tmp_list = [x for x in self._status['zones']
|
||||
if x['temperatureStatus']['isAvailable'] is True]
|
||||
temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list]
|
||||
|
||||
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
|
||||
return avg_temp
|
||||
return self._operation_list
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
@@ -227,47 +218,313 @@ class EvoController(ClimateDevice):
|
||||
@property
|
||||
def precision(self):
|
||||
"""Return the temperature precision to use in the frontend UI."""
|
||||
return PRECISION_TENTHS
|
||||
return PRECISION_HALVES
|
||||
|
||||
|
||||
class EvoZone(EvoClimateDevice):
|
||||
"""Base for a Honeywell evohome Zone device."""
|
||||
|
||||
def __init__(self, evo_data, client, obj_ref):
|
||||
"""Initialize the evohome Zone."""
|
||||
super().__init__(evo_data, client, obj_ref)
|
||||
|
||||
self._id = obj_ref.zoneId
|
||||
self._name = obj_ref.name
|
||||
self._icon = "mdi:radiator"
|
||||
self._type = EVO_CHILD
|
||||
|
||||
for _zone in evo_data['config'][GWS][0][TCS][0]['zones']:
|
||||
if _zone['zoneId'] == self._id:
|
||||
self._config = _zone
|
||||
break
|
||||
self._status = {}
|
||||
|
||||
self._operation_list = ZONE_OP_LIST
|
||||
self._supported_features = \
|
||||
SUPPORT_OPERATION_MODE | \
|
||||
SUPPORT_TARGET_TEMPERATURE | \
|
||||
SUPPORT_ON_OFF
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum target temp (setpoint) of a evohome entity."""
|
||||
return MIN_TEMP
|
||||
"""Return the minimum target temperature of a evohome Zone.
|
||||
|
||||
The default is 5 (in Celsius), but it is configurable within 5-35.
|
||||
"""
|
||||
return self._config['setpointCapabilities']['minHeatSetpoint']
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum target temp (setpoint) of a evohome entity."""
|
||||
return MAX_TEMP
|
||||
"""Return the minimum target temperature of a evohome Zone.
|
||||
|
||||
The default is 35 (in Celsius), but it is configurable within 5-35.
|
||||
"""
|
||||
return self._config['setpointCapabilities']['maxHeatSetpoint']
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true as evohome controllers are always on.
|
||||
def target_temperature(self):
|
||||
"""Return the target temperature of the evohome Zone."""
|
||||
return self._status['setpointStatus']['targetHeatTemperature']
|
||||
|
||||
Operating modes can include 'HeatingOff', but (for example) DHW would
|
||||
remain on.
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature of the evohome Zone."""
|
||||
return self._status['temperatureStatus']['temperature']
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return the current operating mode of the evohome Zone.
|
||||
|
||||
The evohome Zones that are in 'FollowSchedule' mode inherit their
|
||||
actual operating mode from the Controller.
|
||||
"""
|
||||
evo_data = self.hass.data[DATA_EVOHOME]
|
||||
|
||||
system_mode = evo_data['status']['systemModeStatus']['mode']
|
||||
setpoint_mode = self._status['setpointStatus']['setpointMode']
|
||||
|
||||
if setpoint_mode == EVO_FOLLOW:
|
||||
# then inherit state from the controller
|
||||
if system_mode == EVO_RESET:
|
||||
current_operation = TCS_STATE_TO_HA.get(EVO_AUTO)
|
||||
else:
|
||||
current_operation = TCS_STATE_TO_HA.get(system_mode)
|
||||
else:
|
||||
current_operation = ZONE_STATE_TO_HA.get(setpoint_mode)
|
||||
|
||||
return current_operation
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the evohome Zone is off.
|
||||
|
||||
A Zone is considered off if its target temp is set to its minimum, and
|
||||
it is not following its schedule (i.e. not in 'FollowSchedule' mode).
|
||||
"""
|
||||
is_off = \
|
||||
self.target_temperature == self.min_temp and \
|
||||
self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER
|
||||
return not is_off
|
||||
|
||||
def _set_temperature(self, temperature, until=None):
|
||||
"""Set the new target temperature of a Zone.
|
||||
|
||||
temperature is required, until can be:
|
||||
- strftime('%Y-%m-%dT%H:%M:%SZ') for TemporaryOverride, or
|
||||
- None for PermanentOverride (i.e. indefinitely)
|
||||
"""
|
||||
try:
|
||||
self._obj.set_temperature(temperature, until)
|
||||
except HTTPError as err:
|
||||
self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature, indefinitely."""
|
||||
self._set_temperature(kwargs['temperature'], until=None)
|
||||
|
||||
def turn_on(self):
|
||||
"""Turn the evohome Zone on.
|
||||
|
||||
This is achieved by setting the Zone to its 'FollowSchedule' mode.
|
||||
"""
|
||||
self._set_operation_mode(EVO_FOLLOW)
|
||||
|
||||
def turn_off(self):
|
||||
"""Turn the evohome Zone off.
|
||||
|
||||
This is achieved by setting the Zone to its minimum temperature,
|
||||
indefinitely (i.e. 'PermanentOverride' mode).
|
||||
"""
|
||||
self._set_temperature(self.min_temp, until=None)
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set an operating mode for a Zone.
|
||||
|
||||
Currently limited to 'Auto' & 'Manual'. If 'Off' is needed, it can be
|
||||
enabled via turn_off method.
|
||||
|
||||
NB: evohome Zones do not have an operating mode as understood by HA.
|
||||
Instead they usually 'inherit' an operating mode from their controller.
|
||||
|
||||
More correctly, these Zones are in a follow mode, 'FollowSchedule',
|
||||
where their setpoint temperatures are a function of their schedule, and
|
||||
the Controller's operating_mode, e.g. Economy mode is their scheduled
|
||||
setpoint less (usually) 3C.
|
||||
|
||||
Thus, you cannot set a Zone to Away mode, but the location (i.e. the
|
||||
Controller) is set to Away and each Zones's setpoints are adjusted
|
||||
accordingly to some lower temperature.
|
||||
|
||||
However, Zones can override these setpoints, either for a specified
|
||||
period of time, 'TemporaryOverride', after which they will revert back
|
||||
to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'.
|
||||
"""
|
||||
self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode))
|
||||
|
||||
def _set_operation_mode(self, operation_mode):
|
||||
if operation_mode == EVO_FOLLOW:
|
||||
try:
|
||||
self._obj.cancel_temp_override(self._obj)
|
||||
except HTTPError as err:
|
||||
self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member
|
||||
|
||||
elif operation_mode == EVO_TEMPOVER:
|
||||
_LOGGER.error(
|
||||
"_set_operation_mode(op_mode=%s): mode not yet implemented",
|
||||
operation_mode
|
||||
)
|
||||
|
||||
elif operation_mode == EVO_PERMOVER:
|
||||
self._set_temperature(self.target_temperature, until=None)
|
||||
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"_set_operation_mode(op_mode=%s): mode not valid",
|
||||
operation_mode
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return False as evohome child devices should never be polled.
|
||||
|
||||
The evohome Controller will inform its children when to update().
|
||||
"""
|
||||
return False
|
||||
|
||||
def update(self):
|
||||
"""Process the evohome Zone's state data."""
|
||||
evo_data = self.hass.data[DATA_EVOHOME]
|
||||
|
||||
for _zone in evo_data['status']['zones']:
|
||||
if _zone['zoneId'] == self._id:
|
||||
self._status = _zone
|
||||
break
|
||||
|
||||
self._available = True
|
||||
|
||||
|
||||
class EvoController(EvoClimateDevice):
|
||||
"""Base for a Honeywell evohome hub/Controller device.
|
||||
|
||||
The Controller (aka TCS, temperature control system) is the parent of all
|
||||
the child (CH/DHW) devices. It is also a Climate device.
|
||||
"""
|
||||
|
||||
def __init__(self, evo_data, client, obj_ref):
|
||||
"""Initialize the evohome Controller (hub)."""
|
||||
super().__init__(evo_data, client, obj_ref)
|
||||
|
||||
self._id = obj_ref.systemId
|
||||
self._name = '_{}'.format(obj_ref.location.name)
|
||||
self._icon = "mdi:thermostat"
|
||||
self._type = EVO_PARENT
|
||||
|
||||
self._config = evo_data['config'][GWS][0][TCS][0]
|
||||
self._status = evo_data['status']
|
||||
self._timers['statusUpdated'] = datetime.min
|
||||
|
||||
self._operation_list = TCS_OP_LIST
|
||||
self._supported_features = \
|
||||
SUPPORT_OPERATION_MODE | \
|
||||
SUPPORT_AWAY_MODE
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device state attributes of the evohome Controller.
|
||||
|
||||
This is state data that is not available otherwise, due to the
|
||||
restrictions placed upon ClimateDevice properties, etc. by HA.
|
||||
"""
|
||||
status = dict(self._status)
|
||||
|
||||
if 'zones' in status:
|
||||
del status['zones']
|
||||
if 'dhw' in status:
|
||||
del status['dhw']
|
||||
|
||||
return {'status': status}
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return the current operating mode of the evohome Controller."""
|
||||
return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode'])
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum target temperature of a evohome Controller.
|
||||
|
||||
Although evohome Controllers do not have a minimum target temp, one is
|
||||
expected by the HA schema; the default for an evohome HR92 is used.
|
||||
"""
|
||||
return 5
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the minimum target temperature of a evohome Controller.
|
||||
|
||||
Although evohome Controllers do not have a maximum target temp, one is
|
||||
expected by the HA schema; the default for an evohome HR92 is used.
|
||||
"""
|
||||
return 35
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the average target temperature of the Heating/DHW zones.
|
||||
|
||||
Although evohome Controllers do not have a target temp, one is
|
||||
expected by the HA schema.
|
||||
"""
|
||||
temps = [zone['setpointStatus']['targetHeatTemperature']
|
||||
for zone in self._status['zones']]
|
||||
|
||||
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
|
||||
return avg_temp
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the average current temperature of the Heating/DHW zones.
|
||||
|
||||
Although evohome Controllers do not have a target temp, one is
|
||||
expected by the HA schema.
|
||||
"""
|
||||
tmp_list = [x for x in self._status['zones']
|
||||
if x['temperatureStatus']['isAvailable'] is True]
|
||||
temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list]
|
||||
|
||||
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
|
||||
return avg_temp
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True as evohome Controllers are always on.
|
||||
|
||||
For example, evohome Controllers have a 'HeatingOff' mode, but even
|
||||
then the DHW would remain on.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_away_mode_on(self):
|
||||
"""Return true if away mode is on."""
|
||||
def is_away_mode_on(self) -> bool:
|
||||
"""Return True if away mode is on."""
|
||||
return self._status['systemModeStatus']['mode'] == EVO_AWAY
|
||||
|
||||
def turn_away_mode_on(self):
|
||||
"""Turn away mode on."""
|
||||
"""Turn away mode on.
|
||||
|
||||
The evohome Controller will not remember is previous operating mode.
|
||||
"""
|
||||
self._set_operation_mode(EVO_AWAY)
|
||||
|
||||
def turn_away_mode_off(self):
|
||||
"""Turn away mode off."""
|
||||
"""Turn away mode off.
|
||||
|
||||
The evohome Controller can not recall its previous operating mode (as
|
||||
intimated by the HA schema), so this method is achieved by setting the
|
||||
Controller's mode back to Auto.
|
||||
"""
|
||||
self._set_operation_mode(EVO_AUTO)
|
||||
|
||||
def _set_operation_mode(self, operation_mode):
|
||||
# Set new target operation mode for the TCS.
|
||||
_LOGGER.debug(
|
||||
"_set_operation_mode(): API call [1 request(s)]: "
|
||||
"tcs._set_status(%s)...",
|
||||
operation_mode
|
||||
)
|
||||
try:
|
||||
self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access
|
||||
except HTTPError as err:
|
||||
@@ -279,93 +536,45 @@ class EvoController(ClimateDevice):
|
||||
Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away'
|
||||
mode is needed, it can be enabled via turn_away_mode_on method.
|
||||
"""
|
||||
self._set_operation_mode(HA_STATE_TO_EVO.get(operation_mode))
|
||||
self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode))
|
||||
|
||||
def _update_state_data(self, evo_data):
|
||||
client = evo_data['client']
|
||||
loc_idx = evo_data['params'][CONF_LOCATION_IDX]
|
||||
|
||||
_LOGGER.debug(
|
||||
"_update_state_data(): API call [1 request(s)]: "
|
||||
"client.locations[loc_idx].status()..."
|
||||
)
|
||||
|
||||
try:
|
||||
evo_data['status'].update(
|
||||
client.locations[loc_idx].status()[GWS][0][TCS][0])
|
||||
except HTTPError as err: # check if we've exceeded the api rate limit
|
||||
self._handle_requests_exceptions(err)
|
||||
else:
|
||||
evo_data['timers']['statusUpdated'] = datetime.now()
|
||||
|
||||
_LOGGER.debug(
|
||||
"_update_state_data(): evo_data['status'] = %s",
|
||||
evo_data['status']
|
||||
)
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return True as the evohome Controller should always be polled."""
|
||||
return True
|
||||
|
||||
def update(self):
|
||||
"""Get the latest state data of the installation.
|
||||
"""Get the latest state data of the entire evohome Location.
|
||||
|
||||
This includes state data for the Controller and its child devices, such
|
||||
as the operating_mode of the Controller and the current_temperature
|
||||
of its children.
|
||||
|
||||
This is not asyncio-friendly due to the underlying client api.
|
||||
This includes state data for the Controller and all its child devices,
|
||||
such as the operating mode of the Controller and the current temp of
|
||||
its children (e.g. Zones, DHW controller).
|
||||
"""
|
||||
evo_data = self.hass.data[DATA_EVOHOME]
|
||||
|
||||
# should the latest evohome state data be retreived this cycle?
|
||||
timeout = datetime.now() + timedelta(seconds=55)
|
||||
expired = timeout > self._timers['statusUpdated'] + \
|
||||
timedelta(seconds=evo_data['params'][CONF_SCAN_INTERVAL])
|
||||
self._params[CONF_SCAN_INTERVAL]
|
||||
|
||||
if not expired:
|
||||
return
|
||||
|
||||
was_available = self._available or \
|
||||
self._timers['statusUpdated'] == datetime.min
|
||||
|
||||
self._update_state_data(evo_data)
|
||||
self._status = evo_data['status']
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
tmp_dict = dict(self._status)
|
||||
if 'zones' in tmp_dict:
|
||||
tmp_dict['zones'] = '...'
|
||||
if 'dhw' in tmp_dict:
|
||||
tmp_dict['dhw'] = '...'
|
||||
|
||||
_LOGGER.debug(
|
||||
"update(%s), self._status = %s",
|
||||
self._id + " [" + self._name + "]",
|
||||
tmp_dict
|
||||
)
|
||||
|
||||
no_recent_updates = self._timers['statusUpdated'] < datetime.now() - \
|
||||
timedelta(seconds=self._params[CONF_SCAN_INTERVAL] * 3.1)
|
||||
|
||||
if no_recent_updates:
|
||||
self._available = False
|
||||
debug_code = EVO_DEBUG_NO_RECENT_UPDATES
|
||||
|
||||
elif not self._status:
|
||||
# unavailable because no status (but how? other than at startup?)
|
||||
self._available = False
|
||||
debug_code = EVO_DEBUG_NO_STATUS
|
||||
# Retreive the latest state data via the client api
|
||||
loc_idx = self._params[CONF_LOCATION_IDX]
|
||||
|
||||
try:
|
||||
self._status.update(
|
||||
self._client.locations[loc_idx].status()[GWS][0][TCS][0])
|
||||
except HTTPError as err: # check if we've exceeded the api rate limit
|
||||
self._handle_requests_exceptions(err)
|
||||
else:
|
||||
self._timers['statusUpdated'] = datetime.now()
|
||||
self._available = True
|
||||
|
||||
if not self._available and was_available:
|
||||
# only warn if available went from True to False
|
||||
_LOGGER.warning(
|
||||
"The entity, %s, has become unavailable, debug code is: %s",
|
||||
self._id + " [" + self._name + "]",
|
||||
debug_code
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"_update_state_data(): self._status = %s",
|
||||
self._status
|
||||
)
|
||||
|
||||
elif self._available and not was_available:
|
||||
# this isn't the first re-available (e.g. _after_ STARTUP)
|
||||
_LOGGER.debug(
|
||||
"The entity, %s, has become available",
|
||||
self._id + " [" + self._name + "]"
|
||||
)
|
||||
# inform the child devices that state data has been updated
|
||||
pkt = {'sender': 'controller', 'signal': 'refresh', 'to': EVO_CHILD}
|
||||
dispatcher_send(self.hass, DISPATCHER_EVOHOME, pkt)
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers import condition
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change, async_track_time_interval)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.restore_state import async_get_last_state
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,7 +96,7 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
precision)])
|
||||
|
||||
|
||||
class GenericThermostat(ClimateDevice):
|
||||
class GenericThermostat(ClimateDevice, RestoreEntity):
|
||||
"""Representation of a Generic Thermostat device."""
|
||||
|
||||
def __init__(self, hass, name, heater_entity_id, sensor_entity_id,
|
||||
@@ -155,8 +155,9 @@ class GenericThermostat(ClimateDevice):
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
# Check If we have an old state
|
||||
old_state = await async_get_last_state(self.hass, self.entity_id)
|
||||
old_state = await self.async_get_last_state()
|
||||
if old_state is not None:
|
||||
# If we have no initial temperature, restore
|
||||
if self._target_temp is None:
|
||||
|
||||
@@ -50,23 +50,23 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
|
||||
def update_characteristics(self, characteristics):
|
||||
"""Synchronise device state with Home Assistant."""
|
||||
# pylint: disable=import-error
|
||||
from homekit import CharacteristicsTypes as ctypes
|
||||
from homekit.models.characteristics import CharacteristicsTypes
|
||||
|
||||
for characteristic in characteristics:
|
||||
ctype = characteristic['type']
|
||||
if ctype == ctypes.HEATING_COOLING_CURRENT:
|
||||
if ctype == CharacteristicsTypes.HEATING_COOLING_CURRENT:
|
||||
self._state = MODE_HOMEKIT_TO_HASS.get(
|
||||
characteristic['value'])
|
||||
if ctype == ctypes.HEATING_COOLING_TARGET:
|
||||
if ctype == CharacteristicsTypes.HEATING_COOLING_TARGET:
|
||||
self._chars['target_mode'] = characteristic['iid']
|
||||
self._features |= SUPPORT_OPERATION_MODE
|
||||
self._current_mode = MODE_HOMEKIT_TO_HASS.get(
|
||||
characteristic['value'])
|
||||
self._valid_modes = [MODE_HOMEKIT_TO_HASS.get(
|
||||
mode) for mode in characteristic['valid-values']]
|
||||
elif ctype == ctypes.TEMPERATURE_CURRENT:
|
||||
elif ctype == CharacteristicsTypes.TEMPERATURE_CURRENT:
|
||||
self._current_temp = characteristic['value']
|
||||
elif ctype == ctypes.TEMPERATURE_TARGET:
|
||||
elif ctype == CharacteristicsTypes.TEMPERATURE_TARGET:
|
||||
self._chars['target_temp'] = characteristic['iid']
|
||||
self._features |= SUPPORT_TARGET_TEMPERATURE
|
||||
self._target_temp = characteristic['value']
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
|
||||
ATTR_TEMPERATURE, CONF_REGION)
|
||||
|
||||
REQUIREMENTS = ['evohomeclient==0.2.7', 'somecomfort==0.5.2']
|
||||
REQUIREMENTS = ['evohomeclient==0.2.8', 'somecomfort==0.5.2']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -6,14 +6,17 @@ https://home-assistant.io/components/climate.knx/
|
||||
"""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
PLATFORM_SCHEMA, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
|
||||
ClimateDevice)
|
||||
from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX
|
||||
from homeassistant.const import ATTR_TEMPERATURE, CONF_NAME, TEMP_CELSIUS
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.climate import (
|
||||
PLATFORM_SCHEMA, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE, STATE_HEAT,
|
||||
STATE_IDLE, STATE_MANUAL, STATE_DRY,
|
||||
STATE_FAN_ONLY, STATE_ECO, ClimateDevice)
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE, CONF_NAME, TEMP_CELSIUS)
|
||||
from homeassistant.core import callback
|
||||
|
||||
from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES
|
||||
|
||||
CONF_SETPOINT_SHIFT_ADDRESS = 'setpoint_shift_address'
|
||||
CONF_SETPOINT_SHIFT_STATE_ADDRESS = 'setpoint_shift_state_address'
|
||||
@@ -26,10 +29,17 @@ CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address'
|
||||
CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address'
|
||||
CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address'
|
||||
CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address'
|
||||
CONF_CONTROLLER_MODE_ADDRESS = 'controller_mode_address'
|
||||
CONF_CONTROLLER_MODE_STATE_ADDRESS = 'controller_mode_state_address'
|
||||
CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \
|
||||
'operation_mode_frost_protection_address'
|
||||
CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address'
|
||||
CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address'
|
||||
CONF_OPERATION_MODES = 'operation_modes'
|
||||
CONF_ON_OFF_ADDRESS = 'on_off_address'
|
||||
CONF_ON_OFF_STATE_ADDRESS = 'on_off_state_address'
|
||||
CONF_MIN_TEMP = 'min_temp'
|
||||
CONF_MAX_TEMP = 'max_temp'
|
||||
|
||||
DEFAULT_NAME = 'KNX Climate'
|
||||
DEFAULT_SETPOINT_SHIFT_STEP = 0.5
|
||||
@@ -37,6 +47,21 @@ DEFAULT_SETPOINT_SHIFT_MAX = 6
|
||||
DEFAULT_SETPOINT_SHIFT_MIN = -6
|
||||
DEPENDENCIES = ['knx']
|
||||
|
||||
# Map KNX operation modes to HA modes. This list might not be full.
|
||||
OPERATION_MODES = {
|
||||
# Map DPT 201.100 HVAC operating modes
|
||||
"Frost Protection": STATE_MANUAL,
|
||||
"Night": STATE_IDLE,
|
||||
"Standby": STATE_ECO,
|
||||
"Comfort": STATE_HEAT,
|
||||
# Map DPT 201.104 HVAC control modes
|
||||
"Fan only": STATE_FAN_ONLY,
|
||||
"Dehumidification": STATE_DRY
|
||||
}
|
||||
|
||||
OPERATION_MODES_INV = dict((
|
||||
reversed(item) for item in OPERATION_MODES.items()))
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string,
|
||||
@@ -54,9 +79,17 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_CONTROLLER_MODE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_CONTROLLER_MODE_STATE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_ON_OFF_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_ON_OFF_STATE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_OPERATION_MODES): vol.All(cv.ensure_list,
|
||||
[vol.In(OPERATION_MODES)]),
|
||||
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
|
||||
})
|
||||
|
||||
|
||||
@@ -84,6 +117,30 @@ def async_add_entities_config(hass, config, async_add_entities):
|
||||
"""Set up climate for KNX platform configured within platform."""
|
||||
import xknx
|
||||
|
||||
climate_mode = xknx.devices.ClimateMode(
|
||||
hass.data[DATA_KNX].xknx,
|
||||
name=config.get(CONF_NAME) + " Mode",
|
||||
group_address_operation_mode=config.get(CONF_OPERATION_MODE_ADDRESS),
|
||||
group_address_operation_mode_state=config.get(
|
||||
CONF_OPERATION_MODE_STATE_ADDRESS),
|
||||
group_address_controller_status=config.get(
|
||||
CONF_CONTROLLER_STATUS_ADDRESS),
|
||||
group_address_controller_status_state=config.get(
|
||||
CONF_CONTROLLER_STATUS_STATE_ADDRESS),
|
||||
group_address_controller_mode=config.get(
|
||||
CONF_CONTROLLER_MODE_ADDRESS),
|
||||
group_address_controller_mode_state=config.get(
|
||||
CONF_CONTROLLER_MODE_STATE_ADDRESS),
|
||||
group_address_operation_mode_protection=config.get(
|
||||
CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS),
|
||||
group_address_operation_mode_night=config.get(
|
||||
CONF_OPERATION_MODE_NIGHT_ADDRESS),
|
||||
group_address_operation_mode_comfort=config.get(
|
||||
CONF_OPERATION_MODE_COMFORT_ADDRESS),
|
||||
operation_modes=config.get(
|
||||
CONF_OPERATION_MODES))
|
||||
hass.data[DATA_KNX].xknx.devices.add(climate_mode)
|
||||
|
||||
climate = xknx.devices.Climate(
|
||||
hass.data[DATA_KNX].xknx,
|
||||
name=config.get(CONF_NAME),
|
||||
@@ -96,20 +153,15 @@ def async_add_entities_config(hass, config, async_add_entities):
|
||||
setpoint_shift_step=config.get(CONF_SETPOINT_SHIFT_STEP),
|
||||
setpoint_shift_max=config.get(CONF_SETPOINT_SHIFT_MAX),
|
||||
setpoint_shift_min=config.get(CONF_SETPOINT_SHIFT_MIN),
|
||||
group_address_operation_mode=config.get(CONF_OPERATION_MODE_ADDRESS),
|
||||
group_address_operation_mode_state=config.get(
|
||||
CONF_OPERATION_MODE_STATE_ADDRESS),
|
||||
group_address_controller_status=config.get(
|
||||
CONF_CONTROLLER_STATUS_ADDRESS),
|
||||
group_address_controller_status_state=config.get(
|
||||
CONF_CONTROLLER_STATUS_STATE_ADDRESS),
|
||||
group_address_operation_mode_protection=config.get(
|
||||
CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS),
|
||||
group_address_operation_mode_night=config.get(
|
||||
CONF_OPERATION_MODE_NIGHT_ADDRESS),
|
||||
group_address_operation_mode_comfort=config.get(
|
||||
CONF_OPERATION_MODE_COMFORT_ADDRESS))
|
||||
group_address_on_off=config.get(
|
||||
CONF_ON_OFF_ADDRESS),
|
||||
group_address_on_off_state=config.get(
|
||||
CONF_ON_OFF_STATE_ADDRESS),
|
||||
min_temp=config.get(CONF_MIN_TEMP),
|
||||
max_temp=config.get(CONF_MAX_TEMP),
|
||||
mode=climate_mode)
|
||||
hass.data[DATA_KNX].xknx.devices.add(climate)
|
||||
|
||||
async_add_entities([KNXClimate(climate)])
|
||||
|
||||
|
||||
@@ -119,26 +171,25 @@ class KNXClimate(ClimateDevice):
|
||||
def __init__(self, device):
|
||||
"""Initialize of a KNX climate device."""
|
||||
self.device = device
|
||||
self._unit_of_measurement = TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
support = SUPPORT_TARGET_TEMPERATURE
|
||||
if self.device.supports_operation_mode:
|
||||
if self.device.mode.supports_operation_mode:
|
||||
support |= SUPPORT_OPERATION_MODE
|
||||
if self.device.supports_on_off:
|
||||
support |= SUPPORT_ON_OFF
|
||||
return support
|
||||
|
||||
def async_register_callbacks(self):
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks to update hass after device was changed."""
|
||||
async def after_update_callback(device):
|
||||
"""Call after device was updated."""
|
||||
await self.async_update_ha_state()
|
||||
self.device.register_device_updated_cb(after_update_callback)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Store register state change callback."""
|
||||
self.async_register_callbacks()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the KNX device."""
|
||||
@@ -157,7 +208,7 @@ class KNXClimate(ClimateDevice):
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return TEMP_CELSIUS
|
||||
return self._unit_of_measurement
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
@@ -195,20 +246,37 @@ class KNXClimate(ClimateDevice):
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
if self.device.supports_operation_mode:
|
||||
return self.device.operation_mode.value
|
||||
if self.device.mode.supports_operation_mode:
|
||||
return OPERATION_MODES.get(self.device.mode.operation_mode.value)
|
||||
return None
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""Return the list of available operation modes."""
|
||||
return [operation_mode.value for
|
||||
return [OPERATION_MODES.get(operation_mode.value) for
|
||||
operation_mode in
|
||||
self.device.get_supported_operation_modes()]
|
||||
self.device.mode.operation_modes]
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
if self.device.supports_operation_mode:
|
||||
if self.device.mode.supports_operation_mode:
|
||||
from xknx.knx import HVACOperationMode
|
||||
knx_operation_mode = HVACOperationMode(operation_mode)
|
||||
await self.device.set_operation_mode(knx_operation_mode)
|
||||
knx_operation_mode = HVACOperationMode(
|
||||
OPERATION_MODES_INV.get(operation_mode))
|
||||
await self.device.mode.set_operation_mode(knx_operation_mode)
|
||||
await self.async_update_ha_state()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the device is on."""
|
||||
if self.device.supports_on_off:
|
||||
return self.device.is_on
|
||||
return None
|
||||
|
||||
async def async_turn_on(self):
|
||||
"""Turn on."""
|
||||
await self.device.turn_on()
|
||||
|
||||
async def async_turn_off(self):
|
||||
"""Turn off."""
|
||||
await self.device.turn_off()
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
REQUIREMENTS = ['millheater==0.2.8']
|
||||
REQUIREMENTS = ['millheater==0.3.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -18,11 +18,13 @@ from homeassistant.components.climate import (
|
||||
SUPPORT_SWING_MODE, SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE,
|
||||
SUPPORT_AUX_HEAT, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP)
|
||||
from homeassistant.const import (
|
||||
STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, CONF_VALUE_TEMPLATE)
|
||||
ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_ON,
|
||||
STATE_OFF)
|
||||
from homeassistant.components.mqtt import (
|
||||
ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_QOS, CONF_RETAIN,
|
||||
CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability, MqttDiscoveryUpdate)
|
||||
MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability, MqttDiscoveryUpdate,
|
||||
MqttEntityDeviceInfo, subscription)
|
||||
from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -77,6 +79,20 @@ CONF_MIN_TEMP = 'min_temp'
|
||||
CONF_MAX_TEMP = 'max_temp'
|
||||
CONF_TEMP_STEP = 'temp_step'
|
||||
|
||||
CONF_UNIQUE_ID = 'unique_id'
|
||||
|
||||
TEMPLATE_KEYS = (
|
||||
CONF_POWER_STATE_TEMPLATE,
|
||||
CONF_MODE_STATE_TEMPLATE,
|
||||
CONF_TEMPERATURE_STATE_TEMPLATE,
|
||||
CONF_FAN_MODE_STATE_TEMPLATE,
|
||||
CONF_SWING_MODE_STATE_TEMPLATE,
|
||||
CONF_AWAY_MODE_STATE_TEMPLATE,
|
||||
CONF_HOLD_STATE_TEMPLATE,
|
||||
CONF_AUX_STATE_TEMPLATE,
|
||||
CONF_CURRENT_TEMPERATURE_TEMPLATE
|
||||
)
|
||||
|
||||
SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema)
|
||||
PLATFORM_SCHEMA = SCHEMA_BASE.extend({
|
||||
vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
@@ -126,8 +142,9 @@ PLATFORM_SCHEMA = SCHEMA_BASE.extend({
|
||||
|
||||
vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float)
|
||||
|
||||
vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float),
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
|
||||
}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema)
|
||||
|
||||
|
||||
@@ -153,124 +170,118 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
async def _async_setup_entity(hass, config, async_add_entities,
|
||||
discovery_hash=None):
|
||||
"""Set up the MQTT climate devices."""
|
||||
template_keys = (
|
||||
CONF_POWER_STATE_TEMPLATE,
|
||||
CONF_MODE_STATE_TEMPLATE,
|
||||
CONF_TEMPERATURE_STATE_TEMPLATE,
|
||||
CONF_FAN_MODE_STATE_TEMPLATE,
|
||||
CONF_SWING_MODE_STATE_TEMPLATE,
|
||||
CONF_AWAY_MODE_STATE_TEMPLATE,
|
||||
CONF_HOLD_STATE_TEMPLATE,
|
||||
CONF_AUX_STATE_TEMPLATE,
|
||||
CONF_CURRENT_TEMPERATURE_TEMPLATE
|
||||
)
|
||||
value_templates = {}
|
||||
if CONF_VALUE_TEMPLATE in config:
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
value_template.hass = hass
|
||||
value_templates = {key: value_template for key in template_keys}
|
||||
for key in template_keys & config.keys():
|
||||
value_templates[key] = config.get(key)
|
||||
value_templates[key].hass = hass
|
||||
|
||||
async_add_entities([
|
||||
MqttClimate(
|
||||
hass,
|
||||
config.get(CONF_NAME),
|
||||
{
|
||||
key: config.get(key) for key in (
|
||||
CONF_POWER_COMMAND_TOPIC,
|
||||
CONF_MODE_COMMAND_TOPIC,
|
||||
CONF_TEMPERATURE_COMMAND_TOPIC,
|
||||
CONF_FAN_MODE_COMMAND_TOPIC,
|
||||
CONF_SWING_MODE_COMMAND_TOPIC,
|
||||
CONF_AWAY_MODE_COMMAND_TOPIC,
|
||||
CONF_HOLD_COMMAND_TOPIC,
|
||||
CONF_AUX_COMMAND_TOPIC,
|
||||
CONF_POWER_STATE_TOPIC,
|
||||
CONF_MODE_STATE_TOPIC,
|
||||
CONF_TEMPERATURE_STATE_TOPIC,
|
||||
CONF_FAN_MODE_STATE_TOPIC,
|
||||
CONF_SWING_MODE_STATE_TOPIC,
|
||||
CONF_AWAY_MODE_STATE_TOPIC,
|
||||
CONF_HOLD_STATE_TOPIC,
|
||||
CONF_AUX_STATE_TOPIC,
|
||||
CONF_CURRENT_TEMPERATURE_TOPIC
|
||||
)
|
||||
},
|
||||
value_templates,
|
||||
config.get(CONF_QOS),
|
||||
config.get(CONF_RETAIN),
|
||||
config.get(CONF_MODE_LIST),
|
||||
config.get(CONF_FAN_MODE_LIST),
|
||||
config.get(CONF_SWING_MODE_LIST),
|
||||
config.get(CONF_INITIAL),
|
||||
False, None, SPEED_LOW,
|
||||
STATE_OFF, STATE_OFF, False,
|
||||
config.get(CONF_SEND_IF_OFF),
|
||||
config.get(CONF_PAYLOAD_ON),
|
||||
config.get(CONF_PAYLOAD_OFF),
|
||||
config.get(CONF_AVAILABILITY_TOPIC),
|
||||
config.get(CONF_PAYLOAD_AVAILABLE),
|
||||
config.get(CONF_PAYLOAD_NOT_AVAILABLE),
|
||||
config.get(CONF_MIN_TEMP),
|
||||
config.get(CONF_MAX_TEMP),
|
||||
config.get(CONF_TEMP_STEP),
|
||||
config,
|
||||
discovery_hash,
|
||||
)])
|
||||
|
||||
|
||||
class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo,
|
||||
ClimateDevice):
|
||||
"""Representation of an MQTT climate device."""
|
||||
|
||||
def __init__(self, hass, name, topic, value_templates, qos, retain,
|
||||
mode_list, fan_mode_list, swing_mode_list,
|
||||
target_temperature, away, hold, current_fan_mode,
|
||||
current_swing_mode, current_operation, aux, send_if_off,
|
||||
payload_on, payload_off, availability_topic,
|
||||
payload_available, payload_not_available,
|
||||
min_temp, max_temp, temp_step, discovery_hash):
|
||||
def __init__(self, hass, config, discovery_hash):
|
||||
"""Initialize the climate device."""
|
||||
self._config = config
|
||||
self._unique_id = config.get(CONF_UNIQUE_ID)
|
||||
self._sub_state = None
|
||||
|
||||
self.hass = hass
|
||||
self._topic = None
|
||||
self._value_templates = None
|
||||
self._target_temperature = None
|
||||
self._current_fan_mode = None
|
||||
self._current_operation = None
|
||||
self._current_swing_mode = None
|
||||
self._unit_of_measurement = hass.config.units.temperature_unit
|
||||
self._away = False
|
||||
self._hold = None
|
||||
self._current_temperature = None
|
||||
self._aux = False
|
||||
|
||||
self._setup_from_config(config)
|
||||
|
||||
availability_topic = config.get(CONF_AVAILABILITY_TOPIC)
|
||||
payload_available = config.get(CONF_PAYLOAD_AVAILABLE)
|
||||
payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE)
|
||||
qos = config.get(CONF_QOS)
|
||||
device_config = config.get(CONF_DEVICE)
|
||||
|
||||
MqttAvailability.__init__(self, availability_topic, qos,
|
||||
payload_available, payload_not_available)
|
||||
MqttDiscoveryUpdate.__init__(self, discovery_hash)
|
||||
self.hass = hass
|
||||
self._name = name
|
||||
self._topic = topic
|
||||
self._value_templates = value_templates
|
||||
self._qos = qos
|
||||
self._retain = retain
|
||||
MqttDiscoveryUpdate.__init__(self, discovery_hash,
|
||||
self.discovery_update)
|
||||
MqttEntityDeviceInfo.__init__(self, device_config)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Handle being added to home assistant."""
|
||||
await super().async_added_to_hass()
|
||||
await self._subscribe_topics()
|
||||
|
||||
async def discovery_update(self, discovery_payload):
|
||||
"""Handle updated discovery message."""
|
||||
config = PLATFORM_SCHEMA(discovery_payload)
|
||||
self._config = config
|
||||
self._setup_from_config(config)
|
||||
await self.availability_discovery_update(config)
|
||||
await self._subscribe_topics()
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
def _setup_from_config(self, config):
|
||||
"""(Re)Setup the entity."""
|
||||
self._topic = {
|
||||
key: config.get(key) for key in (
|
||||
CONF_POWER_COMMAND_TOPIC,
|
||||
CONF_MODE_COMMAND_TOPIC,
|
||||
CONF_TEMPERATURE_COMMAND_TOPIC,
|
||||
CONF_FAN_MODE_COMMAND_TOPIC,
|
||||
CONF_SWING_MODE_COMMAND_TOPIC,
|
||||
CONF_AWAY_MODE_COMMAND_TOPIC,
|
||||
CONF_HOLD_COMMAND_TOPIC,
|
||||
CONF_AUX_COMMAND_TOPIC,
|
||||
CONF_POWER_STATE_TOPIC,
|
||||
CONF_MODE_STATE_TOPIC,
|
||||
CONF_TEMPERATURE_STATE_TOPIC,
|
||||
CONF_FAN_MODE_STATE_TOPIC,
|
||||
CONF_SWING_MODE_STATE_TOPIC,
|
||||
CONF_AWAY_MODE_STATE_TOPIC,
|
||||
CONF_HOLD_STATE_TOPIC,
|
||||
CONF_AUX_STATE_TOPIC,
|
||||
CONF_CURRENT_TEMPERATURE_TOPIC
|
||||
)
|
||||
}
|
||||
|
||||
# set to None in non-optimistic mode
|
||||
self._target_temperature = self._current_fan_mode = \
|
||||
self._current_operation = self._current_swing_mode = None
|
||||
if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None:
|
||||
self._target_temperature = target_temperature
|
||||
self._unit_of_measurement = hass.config.units.temperature_unit
|
||||
self._away = away
|
||||
self._hold = hold
|
||||
self._current_temperature = None
|
||||
self._target_temperature = config.get(CONF_INITIAL)
|
||||
if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None:
|
||||
self._current_fan_mode = current_fan_mode
|
||||
if self._topic[CONF_MODE_STATE_TOPIC] is None:
|
||||
self._current_operation = current_operation
|
||||
self._aux = aux
|
||||
self._current_fan_mode = SPEED_LOW
|
||||
if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None:
|
||||
self._current_swing_mode = current_swing_mode
|
||||
self._fan_list = fan_mode_list
|
||||
self._operation_list = mode_list
|
||||
self._swing_list = swing_mode_list
|
||||
self._target_temperature_step = temp_step
|
||||
self._send_if_off = send_if_off
|
||||
self._payload_on = payload_on
|
||||
self._payload_off = payload_off
|
||||
self._min_temp = min_temp
|
||||
self._max_temp = max_temp
|
||||
self._discovery_hash = discovery_hash
|
||||
self._current_swing_mode = STATE_OFF
|
||||
if self._topic[CONF_MODE_STATE_TOPIC] is None:
|
||||
self._current_operation = STATE_OFF
|
||||
self._away = False
|
||||
self._hold = None
|
||||
self._aux = False
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Handle being added to home assistant."""
|
||||
await MqttAvailability.async_added_to_hass(self)
|
||||
await MqttDiscoveryUpdate.async_added_to_hass(self)
|
||||
value_templates = {}
|
||||
if CONF_VALUE_TEMPLATE in config:
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
value_template.hass = self.hass
|
||||
value_templates = {key: value_template for key in TEMPLATE_KEYS}
|
||||
for key in TEMPLATE_KEYS & config.keys():
|
||||
value_templates[key] = config.get(key)
|
||||
value_templates[key].hass = self.hass
|
||||
self._value_templates = value_templates
|
||||
|
||||
async def _subscribe_topics(self):
|
||||
"""(Re)Subscribe to topics."""
|
||||
topics = {}
|
||||
qos = self._config.get(CONF_QOS)
|
||||
|
||||
@callback
|
||||
def handle_current_temp_received(topic, payload, qos):
|
||||
@@ -287,9 +298,10 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
_LOGGER.error("Could not parse temperature from %s", payload)
|
||||
|
||||
if self._topic[CONF_CURRENT_TEMPERATURE_TOPIC] is not None:
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_CURRENT_TEMPERATURE_TOPIC],
|
||||
handle_current_temp_received, self._qos)
|
||||
topics[CONF_CURRENT_TEMPERATURE_TOPIC] = {
|
||||
'topic': self._topic[CONF_CURRENT_TEMPERATURE_TOPIC],
|
||||
'msg_callback': handle_current_temp_received,
|
||||
'qos': qos}
|
||||
|
||||
@callback
|
||||
def handle_mode_received(topic, payload, qos):
|
||||
@@ -298,16 +310,17 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
payload = self._value_templates[CONF_MODE_STATE_TEMPLATE].\
|
||||
async_render_with_possible_json_value(payload)
|
||||
|
||||
if payload not in self._operation_list:
|
||||
if payload not in self._config.get(CONF_MODE_LIST):
|
||||
_LOGGER.error("Invalid mode: %s", payload)
|
||||
else:
|
||||
self._current_operation = payload
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_MODE_STATE_TOPIC] is not None:
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_MODE_STATE_TOPIC],
|
||||
handle_mode_received, self._qos)
|
||||
topics[CONF_MODE_STATE_TOPIC] = {
|
||||
'topic': self._topic[CONF_MODE_STATE_TOPIC],
|
||||
'msg_callback': handle_mode_received,
|
||||
'qos': qos}
|
||||
|
||||
@callback
|
||||
def handle_temperature_received(topic, payload, qos):
|
||||
@@ -324,9 +337,10 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
_LOGGER.error("Could not parse temperature from %s", payload)
|
||||
|
||||
if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None:
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_TEMPERATURE_STATE_TOPIC],
|
||||
handle_temperature_received, self._qos)
|
||||
topics[CONF_TEMPERATURE_STATE_TOPIC] = {
|
||||
'topic': self._topic[CONF_TEMPERATURE_STATE_TOPIC],
|
||||
'msg_callback': handle_temperature_received,
|
||||
'qos': qos}
|
||||
|
||||
@callback
|
||||
def handle_fan_mode_received(topic, payload, qos):
|
||||
@@ -336,16 +350,17 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
self._value_templates[CONF_FAN_MODE_STATE_TEMPLATE].\
|
||||
async_render_with_possible_json_value(payload)
|
||||
|
||||
if payload not in self._fan_list:
|
||||
if payload not in self._config.get(CONF_FAN_MODE_LIST):
|
||||
_LOGGER.error("Invalid fan mode: %s", payload)
|
||||
else:
|
||||
self._current_fan_mode = payload
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None:
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_FAN_MODE_STATE_TOPIC],
|
||||
handle_fan_mode_received, self._qos)
|
||||
topics[CONF_FAN_MODE_STATE_TOPIC] = {
|
||||
'topic': self._topic[CONF_FAN_MODE_STATE_TOPIC],
|
||||
'msg_callback': handle_fan_mode_received,
|
||||
'qos': qos}
|
||||
|
||||
@callback
|
||||
def handle_swing_mode_received(topic, payload, qos):
|
||||
@@ -355,32 +370,35 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
self._value_templates[CONF_SWING_MODE_STATE_TEMPLATE].\
|
||||
async_render_with_possible_json_value(payload)
|
||||
|
||||
if payload not in self._swing_list:
|
||||
if payload not in self._config.get(CONF_SWING_MODE_LIST):
|
||||
_LOGGER.error("Invalid swing mode: %s", payload)
|
||||
else:
|
||||
self._current_swing_mode = payload
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None:
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_SWING_MODE_STATE_TOPIC],
|
||||
handle_swing_mode_received, self._qos)
|
||||
topics[CONF_SWING_MODE_STATE_TOPIC] = {
|
||||
'topic': self._topic[CONF_SWING_MODE_STATE_TOPIC],
|
||||
'msg_callback': handle_swing_mode_received,
|
||||
'qos': qos}
|
||||
|
||||
@callback
|
||||
def handle_away_mode_received(topic, payload, qos):
|
||||
"""Handle receiving away mode via MQTT."""
|
||||
payload_on = self._config.get(CONF_PAYLOAD_ON)
|
||||
payload_off = self._config.get(CONF_PAYLOAD_OFF)
|
||||
if CONF_AWAY_MODE_STATE_TEMPLATE in self._value_templates:
|
||||
payload = \
|
||||
self._value_templates[CONF_AWAY_MODE_STATE_TEMPLATE].\
|
||||
async_render_with_possible_json_value(payload)
|
||||
if payload == "True":
|
||||
payload = self._payload_on
|
||||
payload = payload_on
|
||||
elif payload == "False":
|
||||
payload = self._payload_off
|
||||
payload = payload_off
|
||||
|
||||
if payload == self._payload_on:
|
||||
if payload == payload_on:
|
||||
self._away = True
|
||||
elif payload == self._payload_off:
|
||||
elif payload == payload_off:
|
||||
self._away = False
|
||||
else:
|
||||
_LOGGER.error("Invalid away mode: %s", payload)
|
||||
@@ -388,24 +406,27 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None:
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_AWAY_MODE_STATE_TOPIC],
|
||||
handle_away_mode_received, self._qos)
|
||||
topics[CONF_AWAY_MODE_STATE_TOPIC] = {
|
||||
'topic': self._topic[CONF_AWAY_MODE_STATE_TOPIC],
|
||||
'msg_callback': handle_away_mode_received,
|
||||
'qos': qos}
|
||||
|
||||
@callback
|
||||
def handle_aux_mode_received(topic, payload, qos):
|
||||
"""Handle receiving aux mode via MQTT."""
|
||||
payload_on = self._config.get(CONF_PAYLOAD_ON)
|
||||
payload_off = self._config.get(CONF_PAYLOAD_OFF)
|
||||
if CONF_AUX_STATE_TEMPLATE in self._value_templates:
|
||||
payload = self._value_templates[CONF_AUX_STATE_TEMPLATE].\
|
||||
async_render_with_possible_json_value(payload)
|
||||
if payload == "True":
|
||||
payload = self._payload_on
|
||||
payload = payload_on
|
||||
elif payload == "False":
|
||||
payload = self._payload_off
|
||||
payload = payload_off
|
||||
|
||||
if payload == self._payload_on:
|
||||
if payload == payload_on:
|
||||
self._aux = True
|
||||
elif payload == self._payload_off:
|
||||
elif payload == payload_off:
|
||||
self._aux = False
|
||||
else:
|
||||
_LOGGER.error("Invalid aux mode: %s", payload)
|
||||
@@ -413,9 +434,10 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_AUX_STATE_TOPIC] is not None:
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_AUX_STATE_TOPIC],
|
||||
handle_aux_mode_received, self._qos)
|
||||
topics[CONF_AUX_STATE_TOPIC] = {
|
||||
'topic': self._topic[CONF_AUX_STATE_TOPIC],
|
||||
'msg_callback': handle_aux_mode_received,
|
||||
'qos': qos}
|
||||
|
||||
@callback
|
||||
def handle_hold_mode_received(topic, payload, qos):
|
||||
@@ -428,9 +450,20 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_HOLD_STATE_TOPIC] is not None:
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_HOLD_STATE_TOPIC],
|
||||
handle_hold_mode_received, self._qos)
|
||||
topics[CONF_HOLD_STATE_TOPIC] = {
|
||||
'topic': self._topic[CONF_HOLD_STATE_TOPIC],
|
||||
'msg_callback': handle_hold_mode_received,
|
||||
'qos': qos}
|
||||
|
||||
self._sub_state = await subscription.async_subscribe_topics(
|
||||
self.hass, self._sub_state,
|
||||
topics)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Unsubscribe when removed."""
|
||||
self._sub_state = await subscription.async_unsubscribe_topics(
|
||||
self.hass, self._sub_state)
|
||||
await MqttAvailability.async_will_remove_from_hass(self)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -440,7 +473,12 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the climate device."""
|
||||
return self._name
|
||||
return self._config.get(CONF_NAME)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
@@ -465,12 +503,12 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""Return the list of available operation modes."""
|
||||
return self._operation_list
|
||||
return self._config.get(CONF_MODE_LIST)
|
||||
|
||||
@property
|
||||
def target_temperature_step(self):
|
||||
"""Return the supported step of target temperature."""
|
||||
return self._target_temperature_step
|
||||
return self._config.get(CONF_TEMP_STEP)
|
||||
|
||||
@property
|
||||
def is_away_mode_on(self):
|
||||
@@ -495,7 +533,7 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
@property
|
||||
def fan_list(self):
|
||||
"""Return the list of available fan modes."""
|
||||
return self._fan_list
|
||||
return self._config.get(CONF_FAN_MODE_LIST)
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
"""Set new target temperatures."""
|
||||
@@ -508,19 +546,23 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
# optimistic mode
|
||||
self._target_temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
|
||||
if self._send_if_off or self._current_operation != STATE_OFF:
|
||||
if (self._config.get(CONF_SEND_IF_OFF) or
|
||||
self._current_operation != STATE_OFF):
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_TEMPERATURE_COMMAND_TOPIC],
|
||||
kwargs.get(ATTR_TEMPERATURE), self._qos, self._retain)
|
||||
kwargs.get(ATTR_TEMPERATURE), self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode):
|
||||
"""Set new swing mode."""
|
||||
if self._send_if_off or self._current_operation != STATE_OFF:
|
||||
if (self._config.get(CONF_SEND_IF_OFF) or
|
||||
self._current_operation != STATE_OFF):
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_SWING_MODE_COMMAND_TOPIC],
|
||||
swing_mode, self._qos, self._retain)
|
||||
swing_mode, self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None:
|
||||
self._current_swing_mode = swing_mode
|
||||
@@ -528,10 +570,12 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode):
|
||||
"""Set new target temperature."""
|
||||
if self._send_if_off or self._current_operation != STATE_OFF:
|
||||
if (self._config.get(CONF_SEND_IF_OFF) or
|
||||
self._current_operation != STATE_OFF):
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC],
|
||||
fan_mode, self._qos, self._retain)
|
||||
fan_mode, self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None:
|
||||
self._current_fan_mode = fan_mode
|
||||
@@ -539,22 +583,24 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode) -> None:
|
||||
"""Set new operation mode."""
|
||||
qos = self._config.get(CONF_QOS)
|
||||
retain = self._config.get(CONF_RETAIN)
|
||||
if self._topic[CONF_POWER_COMMAND_TOPIC] is not None:
|
||||
if (self._current_operation == STATE_OFF and
|
||||
operation_mode != STATE_OFF):
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_POWER_COMMAND_TOPIC],
|
||||
self._payload_on, self._qos, self._retain)
|
||||
self._config.get(CONF_PAYLOAD_ON), qos, retain)
|
||||
elif (self._current_operation != STATE_OFF and
|
||||
operation_mode == STATE_OFF):
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_POWER_COMMAND_TOPIC],
|
||||
self._payload_off, self._qos, self._retain)
|
||||
self._config.get(CONF_PAYLOAD_OFF), qos, retain)
|
||||
|
||||
if self._topic[CONF_MODE_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_MODE_COMMAND_TOPIC],
|
||||
operation_mode, self._qos, self._retain)
|
||||
operation_mode, qos, retain)
|
||||
|
||||
if self._topic[CONF_MODE_STATE_TOPIC] is None:
|
||||
self._current_operation = operation_mode
|
||||
@@ -568,14 +614,16 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
@property
|
||||
def swing_list(self):
|
||||
"""List of available swing modes."""
|
||||
return self._swing_list
|
||||
return self._config.get(CONF_SWING_MODE_LIST)
|
||||
|
||||
async def async_turn_away_mode_on(self):
|
||||
"""Turn away mode on."""
|
||||
if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass,
|
||||
self._topic[CONF_AWAY_MODE_COMMAND_TOPIC],
|
||||
self._payload_on, self._qos, self._retain)
|
||||
self._config.get(CONF_PAYLOAD_ON),
|
||||
self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None:
|
||||
self._away = True
|
||||
@@ -586,7 +634,9 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass,
|
||||
self._topic[CONF_AWAY_MODE_COMMAND_TOPIC],
|
||||
self._payload_off, self._qos, self._retain)
|
||||
self._config.get(CONF_PAYLOAD_OFF),
|
||||
self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None:
|
||||
self._away = False
|
||||
@@ -597,7 +647,8 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass,
|
||||
self._topic[CONF_HOLD_COMMAND_TOPIC],
|
||||
hold_mode, self._qos, self._retain)
|
||||
hold_mode, self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
if self._topic[CONF_HOLD_STATE_TOPIC] is None:
|
||||
self._hold = hold_mode
|
||||
@@ -607,7 +658,9 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
"""Turn auxiliary heater on."""
|
||||
if self._topic[CONF_AUX_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC],
|
||||
self._payload_on, self._qos, self._retain)
|
||||
self._config.get(CONF_PAYLOAD_ON),
|
||||
self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
if self._topic[CONF_AUX_STATE_TOPIC] is None:
|
||||
self._aux = True
|
||||
@@ -617,7 +670,9 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
"""Turn auxiliary heater off."""
|
||||
if self._topic[CONF_AUX_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC],
|
||||
self._payload_off, self._qos, self._retain)
|
||||
self._config.get(CONF_PAYLOAD_OFF),
|
||||
self._config.get(CONF_QOS),
|
||||
self._config.get(CONF_RETAIN))
|
||||
|
||||
if self._topic[CONF_AUX_STATE_TOPIC] is None:
|
||||
self._aux = False
|
||||
@@ -661,9 +716,9 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice):
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return self._min_temp
|
||||
return self._config.get(CONF_MIN_TEMP)
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return self._max_temp
|
||||
return self._config.get(CONF_MAX_TEMP)
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.const import (
|
||||
CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, PRECISION_HALVES)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['radiotherm==1.4.1']
|
||||
REQUIREMENTS = ['radiotherm==2.0.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -235,13 +235,15 @@ class RadioThermostat(ClimateDevice):
|
||||
self._name = self.device.name['raw']
|
||||
|
||||
# Request the current state from the thermostat.
|
||||
data = self.device.tstat['raw']
|
||||
import radiotherm
|
||||
try:
|
||||
data = self.device.tstat['raw']
|
||||
except radiotherm.validate.RadiothermTstatError:
|
||||
_LOGGER.error('%s (%s) was busy (invalid value returned)',
|
||||
self._name, self.device.host)
|
||||
return
|
||||
|
||||
current_temp = data['temp']
|
||||
if current_temp == -1:
|
||||
_LOGGER.error('%s (%s) was busy (temp == -1)', self._name,
|
||||
self.device.host)
|
||||
return
|
||||
|
||||
# Map thermostat values into various STATE_ flags.
|
||||
self._current_temperature = current_temp
|
||||
|
||||
@@ -15,6 +15,14 @@ from homeassistant.const import TEMP_CELSIUS
|
||||
|
||||
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
|
||||
|
||||
HA_TOON = {
|
||||
STATE_AUTO: 'Comfort',
|
||||
STATE_HEAT: 'Home',
|
||||
STATE_ECO: 'Away',
|
||||
STATE_COOL: 'Sleep',
|
||||
}
|
||||
TOON_HA = {value: key for key, value in HA_TOON.items()}
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Toon climate device."""
|
||||
@@ -58,8 +66,7 @@ class ThermostatDevice(ClimateDevice):
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation i.e. comfort, home, away."""
|
||||
state = self.thermos.get_data('state')
|
||||
return state
|
||||
return TOON_HA.get(self.thermos.get_data('state'))
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
@@ -83,14 +90,7 @@ class ThermostatDevice(ClimateDevice):
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set new operation mode."""
|
||||
toonlib_values = {
|
||||
STATE_AUTO: 'Comfort',
|
||||
STATE_HEAT: 'Home',
|
||||
STATE_ECO: 'Away',
|
||||
STATE_COOL: 'Sleep',
|
||||
}
|
||||
|
||||
self.thermos.set_state(toonlib_values[operation_mode])
|
||||
self.thermos.set_state(HA_TOON[operation_mode])
|
||||
|
||||
def update(self):
|
||||
"""Update local state."""
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.google_assistant import helpers as ga_h
|
||||
from homeassistant.components.google_assistant import const as ga_c
|
||||
|
||||
from . import http_api, iot, auth_api, prefs
|
||||
from . import http_api, iot, auth_api, prefs, cloudhooks
|
||||
from .const import CONFIG_DIR, DOMAIN, SERVERS
|
||||
|
||||
REQUIREMENTS = ['warrant==0.6.1']
|
||||
@@ -37,6 +37,7 @@ CONF_RELAYER = 'relayer'
|
||||
CONF_USER_POOL_ID = 'user_pool_id'
|
||||
CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
|
||||
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
|
||||
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
|
||||
|
||||
DEFAULT_MODE = 'production'
|
||||
DEPENDENCIES = ['http']
|
||||
@@ -78,6 +79,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_RELAYER): str,
|
||||
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str,
|
||||
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str,
|
||||
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str,
|
||||
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
|
||||
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
||||
}),
|
||||
@@ -97,6 +99,8 @@ async def async_setup(hass, config):
|
||||
kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({})
|
||||
|
||||
kwargs[CONF_ALEXA] = alexa_sh.Config(
|
||||
endpoint=None,
|
||||
async_get_access_token=None,
|
||||
should_expose=alexa_conf[CONF_FILTER],
|
||||
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
|
||||
)
|
||||
@@ -113,7 +117,7 @@ class Cloud:
|
||||
def __init__(self, hass, mode, alexa, google_actions,
|
||||
cognito_client_id=None, user_pool_id=None, region=None,
|
||||
relayer=None, google_actions_sync_url=None,
|
||||
subscription_info_url=None):
|
||||
subscription_info_url=None, cloudhook_create_url=None):
|
||||
"""Create an instance of Cloud."""
|
||||
self.hass = hass
|
||||
self.mode = mode
|
||||
@@ -125,6 +129,7 @@ class Cloud:
|
||||
self.access_token = None
|
||||
self.refresh_token = None
|
||||
self.iot = iot.CloudIoT(self)
|
||||
self.cloudhooks = cloudhooks.Cloudhooks(self)
|
||||
|
||||
if mode == MODE_DEV:
|
||||
self.cognito_client_id = cognito_client_id
|
||||
@@ -133,6 +138,7 @@ class Cloud:
|
||||
self.relayer = relayer
|
||||
self.google_actions_sync_url = google_actions_sync_url
|
||||
self.subscription_info_url = subscription_info_url
|
||||
self.cloudhook_create_url = cloudhook_create_url
|
||||
|
||||
else:
|
||||
info = SERVERS[mode]
|
||||
@@ -143,6 +149,7 @@ class Cloud:
|
||||
self.relayer = info['relayer']
|
||||
self.google_actions_sync_url = info['google_actions_sync_url']
|
||||
self.subscription_info_url = info['subscription_info_url']
|
||||
self.cloudhook_create_url = info['cloudhook_create_url']
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
@@ -186,9 +193,9 @@ class Cloud:
|
||||
|
||||
self._gactions_config = ga_h.Config(
|
||||
should_expose=should_expose,
|
||||
allow_unlock=self.prefs.google_allow_unlock,
|
||||
agent_user_id=self.claims['cognito:username'],
|
||||
entity_config=conf.get(CONF_ENTITY_CONFIG),
|
||||
allow_unlock=self.prefs.google_allow_unlock,
|
||||
)
|
||||
|
||||
return self._gactions_config
|
||||
@@ -247,8 +254,7 @@ class Cloud:
|
||||
return json.loads(file.read())
|
||||
|
||||
info = await self.hass.async_add_job(load_config)
|
||||
|
||||
await self.prefs.async_initialize(bool(info))
|
||||
await self.prefs.async_initialize()
|
||||
|
||||
if info is None:
|
||||
return
|
||||
|
||||
42
homeassistant/components/cloud/cloud_api.py
Normal file
42
homeassistant/components/cloud/cloud_api.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Cloud APIs."""
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
from . import auth_api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_token(func):
|
||||
"""Decorate a function to verify valid token."""
|
||||
@wraps(func)
|
||||
async def check_token(cloud, *args):
|
||||
"""Validate token, then call func."""
|
||||
await cloud.hass.async_add_executor_job(auth_api.check_token, cloud)
|
||||
return await func(cloud, *args)
|
||||
|
||||
return check_token
|
||||
|
||||
|
||||
def _log_response(func):
|
||||
"""Decorate a function to log bad responses."""
|
||||
@wraps(func)
|
||||
async def log_response(*args):
|
||||
"""Log response if it's bad."""
|
||||
resp = await func(*args)
|
||||
meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning
|
||||
meth('Fetched %s (%s)', resp.url, resp.status)
|
||||
return resp
|
||||
|
||||
return log_response
|
||||
|
||||
|
||||
@_check_token
|
||||
@_log_response
|
||||
async def async_create_cloudhook(cloud):
|
||||
"""Create a cloudhook."""
|
||||
websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession()
|
||||
return await websession.post(
|
||||
cloud.cloudhook_create_url, headers={
|
||||
'authorization': cloud.id_token
|
||||
})
|
||||
66
homeassistant/components/cloud/cloudhooks.py
Normal file
66
homeassistant/components/cloud/cloudhooks.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Manage cloud cloudhooks."""
|
||||
import async_timeout
|
||||
|
||||
from . import cloud_api
|
||||
|
||||
|
||||
class Cloudhooks:
|
||||
"""Class to help manage cloudhooks."""
|
||||
|
||||
def __init__(self, cloud):
|
||||
"""Initialize cloudhooks."""
|
||||
self.cloud = cloud
|
||||
self.cloud.iot.register_on_connect(self.async_publish_cloudhooks)
|
||||
|
||||
async def async_publish_cloudhooks(self):
|
||||
"""Inform the Relayer of the cloudhooks that we support."""
|
||||
cloudhooks = self.cloud.prefs.cloudhooks
|
||||
await self.cloud.iot.async_send_message('webhook-register', {
|
||||
'cloudhook_ids': [info['cloudhook_id'] for info
|
||||
in cloudhooks.values()]
|
||||
}, expect_answer=False)
|
||||
|
||||
async def async_create(self, webhook_id):
|
||||
"""Create a cloud webhook."""
|
||||
cloudhooks = self.cloud.prefs.cloudhooks
|
||||
|
||||
if webhook_id in cloudhooks:
|
||||
raise ValueError('Hook is already enabled for the cloud.')
|
||||
|
||||
if not self.cloud.iot.connected:
|
||||
raise ValueError("Cloud is not connected")
|
||||
|
||||
# Create cloud hook
|
||||
with async_timeout.timeout(10):
|
||||
resp = await cloud_api.async_create_cloudhook(self.cloud)
|
||||
|
||||
data = await resp.json()
|
||||
cloudhook_id = data['cloudhook_id']
|
||||
cloudhook_url = data['url']
|
||||
|
||||
# Store hook
|
||||
cloudhooks = dict(cloudhooks)
|
||||
hook = cloudhooks[webhook_id] = {
|
||||
'webhook_id': webhook_id,
|
||||
'cloudhook_id': cloudhook_id,
|
||||
'cloudhook_url': cloudhook_url
|
||||
}
|
||||
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)
|
||||
|
||||
await self.async_publish_cloudhooks()
|
||||
|
||||
return hook
|
||||
|
||||
async def async_delete(self, webhook_id):
|
||||
"""Delete a cloud webhook."""
|
||||
cloudhooks = self.cloud.prefs.cloudhooks
|
||||
|
||||
if webhook_id not in cloudhooks:
|
||||
raise ValueError('Hook is not enabled for the cloud.')
|
||||
|
||||
# Remove hook
|
||||
cloudhooks = dict(cloudhooks)
|
||||
cloudhooks.pop(webhook_id)
|
||||
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)
|
||||
|
||||
await self.async_publish_cloudhooks()
|
||||
@@ -6,6 +6,7 @@ REQUEST_TIMEOUT = 10
|
||||
PREF_ENABLE_ALEXA = 'alexa_enabled'
|
||||
PREF_ENABLE_GOOGLE = 'google_enabled'
|
||||
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
|
||||
PREF_CLOUDHOOKS = 'cloudhooks'
|
||||
|
||||
SERVERS = {
|
||||
'production': {
|
||||
@@ -16,7 +17,8 @@ SERVERS = {
|
||||
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.'
|
||||
'amazonaws.com/prod/smart_home_sync'),
|
||||
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/'
|
||||
'subscription_info')
|
||||
'subscription_info'),
|
||||
'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncio
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -44,6 +45,20 @@ SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
WS_TYPE_HOOK_CREATE = 'cloud/cloudhook/create'
|
||||
SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_HOOK_CREATE,
|
||||
vol.Required('webhook_id'): str
|
||||
})
|
||||
|
||||
|
||||
WS_TYPE_HOOK_DELETE = 'cloud/cloudhook/delete'
|
||||
SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_HOOK_DELETE,
|
||||
vol.Required('webhook_id'): str
|
||||
})
|
||||
|
||||
|
||||
async def async_setup(hass):
|
||||
"""Initialize the HTTP API."""
|
||||
hass.components.websocket_api.async_register_command(
|
||||
@@ -58,6 +73,14 @@ async def async_setup(hass):
|
||||
WS_TYPE_UPDATE_PREFS, websocket_update_prefs,
|
||||
SCHEMA_WS_UPDATE_PREFS
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_HOOK_CREATE, websocket_hook_create,
|
||||
SCHEMA_WS_HOOK_CREATE
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_HOOK_DELETE, websocket_hook_delete,
|
||||
SCHEMA_WS_HOOK_DELETE
|
||||
)
|
||||
hass.http.register_view(GoogleActionsSyncView)
|
||||
hass.http.register_view(CloudLoginView)
|
||||
hass.http.register_view(CloudLogoutView)
|
||||
@@ -76,7 +99,7 @@ _CLOUD_ERRORS = {
|
||||
|
||||
|
||||
def _handle_cloud_errors(handler):
|
||||
"""Handle auth errors."""
|
||||
"""Webview decorator to handle auth errors."""
|
||||
@wraps(handler)
|
||||
async def error_handler(view, request, *args, **kwargs):
|
||||
"""Handle exceptions that raise from the wrapped request handler."""
|
||||
@@ -240,17 +263,49 @@ def websocket_cloud_status(hass, connection, msg):
|
||||
websocket_api.result_message(msg['id'], _account_data(cloud)))
|
||||
|
||||
|
||||
def _require_cloud_login(handler):
|
||||
"""Websocket decorator that requires cloud to be logged in."""
|
||||
@wraps(handler)
|
||||
def with_cloud_auth(hass, connection, msg):
|
||||
"""Require to be logged into the cloud."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
if not cloud.is_logged_in:
|
||||
connection.send_message(websocket_api.error_message(
|
||||
msg['id'], 'not_logged_in',
|
||||
'You need to be logged in to the cloud.'))
|
||||
return
|
||||
|
||||
handler(hass, connection, msg)
|
||||
|
||||
return with_cloud_auth
|
||||
|
||||
|
||||
def _handle_aiohttp_errors(handler):
|
||||
"""Websocket decorator that handlers aiohttp errors.
|
||||
|
||||
Can only wrap async handlers.
|
||||
"""
|
||||
@wraps(handler)
|
||||
async def with_error_handling(hass, connection, msg):
|
||||
"""Handle aiohttp errors."""
|
||||
try:
|
||||
await handler(hass, connection, msg)
|
||||
except asyncio.TimeoutError:
|
||||
connection.send_message(websocket_api.error_message(
|
||||
msg['id'], 'timeout', 'Command timed out.'))
|
||||
except aiohttp.ClientError:
|
||||
connection.send_message(websocket_api.error_message(
|
||||
msg['id'], 'unknown', 'Error making request.'))
|
||||
|
||||
return with_error_handling
|
||||
|
||||
|
||||
@_require_cloud_login
|
||||
@websocket_api.async_response
|
||||
async def websocket_subscription(hass, connection, msg):
|
||||
"""Handle request for account info."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
if not cloud.is_logged_in:
|
||||
connection.send_message(websocket_api.error_message(
|
||||
msg['id'], 'not_logged_in',
|
||||
'You need to be logged in to the cloud.'))
|
||||
return
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
response = await cloud.fetch_subscription_info()
|
||||
|
||||
@@ -277,24 +332,37 @@ async def websocket_subscription(hass, connection, msg):
|
||||
connection.send_message(websocket_api.result_message(msg['id'], data))
|
||||
|
||||
|
||||
@_require_cloud_login
|
||||
@websocket_api.async_response
|
||||
async def websocket_update_prefs(hass, connection, msg):
|
||||
"""Handle request for account info."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
if not cloud.is_logged_in:
|
||||
connection.send_message(websocket_api.error_message(
|
||||
msg['id'], 'not_logged_in',
|
||||
'You need to be logged in to the cloud.'))
|
||||
return
|
||||
|
||||
changes = dict(msg)
|
||||
changes.pop('id')
|
||||
changes.pop('type')
|
||||
await cloud.prefs.async_update(**changes)
|
||||
|
||||
connection.send_message(websocket_api.result_message(
|
||||
msg['id'], {'success': True}))
|
||||
connection.send_message(websocket_api.result_message(msg['id']))
|
||||
|
||||
|
||||
@_require_cloud_login
|
||||
@websocket_api.async_response
|
||||
@_handle_aiohttp_errors
|
||||
async def websocket_hook_create(hass, connection, msg):
|
||||
"""Handle request for account info."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
hook = await cloud.cloudhooks.async_create(msg['webhook_id'])
|
||||
connection.send_message(websocket_api.result_message(msg['id'], hook))
|
||||
|
||||
|
||||
@_require_cloud_login
|
||||
@websocket_api.async_response
|
||||
async def websocket_hook_delete(hass, connection, msg):
|
||||
"""Handle request for account info."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
await cloud.cloudhooks.async_delete(msg['webhook_id'])
|
||||
connection.send_message(websocket_api.result_message(msg['id']))
|
||||
|
||||
|
||||
def _account_data(cloud):
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import pprint
|
||||
import uuid
|
||||
|
||||
from aiohttp import hdrs, client_exceptions, WSMsgType
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.components.alexa import smart_home as alexa
|
||||
from homeassistant.components.google_assistant import smart_home as ga
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.util.aiohttp import MockRequest, serialize_response
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from . import auth_api
|
||||
from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL
|
||||
@@ -25,6 +28,19 @@ class UnknownHandler(Exception):
|
||||
"""Exception raised when trying to handle unknown handler."""
|
||||
|
||||
|
||||
class NotConnected(Exception):
|
||||
"""Exception raised when trying to handle unknown handler."""
|
||||
|
||||
|
||||
class ErrorMessage(Exception):
|
||||
"""Exception raised when there was error handling message in the cloud."""
|
||||
|
||||
def __init__(self, error):
|
||||
"""Initialize Error Message."""
|
||||
super().__init__(self, "Error in Cloud")
|
||||
self.error = error
|
||||
|
||||
|
||||
class CloudIoT:
|
||||
"""Class to manage the IoT connection."""
|
||||
|
||||
@@ -41,6 +57,19 @@ class CloudIoT:
|
||||
self.tries = 0
|
||||
# Current state of the connection
|
||||
self.state = STATE_DISCONNECTED
|
||||
# Local code waiting for a response
|
||||
self._response_handler = {}
|
||||
self._on_connect = []
|
||||
|
||||
@callback
|
||||
def register_on_connect(self, on_connect_cb):
|
||||
"""Register an async on_connect callback."""
|
||||
self._on_connect.append(on_connect_cb)
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Return if we're currently connected."""
|
||||
return self.state == STATE_CONNECTED
|
||||
|
||||
@asyncio.coroutine
|
||||
def connect(self):
|
||||
@@ -91,6 +120,30 @@ class CloudIoT:
|
||||
if remove_hass_stop_listener is not None:
|
||||
remove_hass_stop_listener()
|
||||
|
||||
async def async_send_message(self, handler, payload,
|
||||
expect_answer=True):
|
||||
"""Send a message."""
|
||||
if self.state != STATE_CONNECTED:
|
||||
raise NotConnected
|
||||
|
||||
msgid = uuid.uuid4().hex
|
||||
|
||||
if expect_answer:
|
||||
fut = self._response_handler[msgid] = asyncio.Future()
|
||||
|
||||
message = {
|
||||
'msgid': msgid,
|
||||
'handler': handler,
|
||||
'payload': payload,
|
||||
}
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("Publishing message:\n%s\n",
|
||||
pprint.pformat(message))
|
||||
await self.client.send_json(message)
|
||||
|
||||
if expect_answer:
|
||||
return await fut
|
||||
|
||||
@asyncio.coroutine
|
||||
def _handle_connection(self):
|
||||
"""Connect to the IoT broker."""
|
||||
@@ -134,6 +187,9 @@ class CloudIoT:
|
||||
_LOGGER.info("Connected")
|
||||
self.state = STATE_CONNECTED
|
||||
|
||||
if self._on_connect:
|
||||
yield from asyncio.wait([cb() for cb in self._on_connect])
|
||||
|
||||
while not client.closed:
|
||||
msg = yield from client.receive()
|
||||
|
||||
@@ -159,6 +215,17 @@ class CloudIoT:
|
||||
_LOGGER.debug("Received message:\n%s\n",
|
||||
pprint.pformat(msg))
|
||||
|
||||
response_handler = self._response_handler.pop(msg['msgid'],
|
||||
None)
|
||||
|
||||
if response_handler is not None:
|
||||
if 'payload' in msg:
|
||||
response_handler.set_result(msg["payload"])
|
||||
else:
|
||||
response_handler.set_exception(
|
||||
ErrorMessage(msg['error']))
|
||||
continue
|
||||
|
||||
response = {
|
||||
'msgid': msg['msgid'],
|
||||
}
|
||||
@@ -257,3 +324,43 @@ def async_handle_cloud(hass, cloud, payload):
|
||||
payload['reason'])
|
||||
else:
|
||||
_LOGGER.warning("Received unknown cloud action: %s", action)
|
||||
|
||||
|
||||
@HANDLERS.register('webhook')
|
||||
async def async_handle_webhook(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for cloud webhooks."""
|
||||
cloudhook_id = payload['cloudhook_id']
|
||||
|
||||
found = None
|
||||
for cloudhook in cloud.prefs.cloudhooks.values():
|
||||
if cloudhook['cloudhook_id'] == cloudhook_id:
|
||||
found = cloudhook
|
||||
break
|
||||
|
||||
if found is None:
|
||||
return {
|
||||
'status': 200
|
||||
}
|
||||
|
||||
request = MockRequest(
|
||||
content=payload['body'].encode('utf-8'),
|
||||
headers=payload['headers'],
|
||||
method=payload['method'],
|
||||
query_string=payload['query'],
|
||||
)
|
||||
|
||||
response = await hass.components.webhook.async_handle_webhook(
|
||||
found['webhook_id'], request)
|
||||
|
||||
response_dict = serialize_response(response)
|
||||
body = response_dict.get('body')
|
||||
if body:
|
||||
body = body.decode('utf-8')
|
||||
|
||||
return {
|
||||
'body': body,
|
||||
'status': response_dict['status'],
|
||||
'headers': {
|
||||
'Content-Type': response.content_type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Preference management for cloud."""
|
||||
from .const import (
|
||||
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK)
|
||||
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS)
|
||||
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
@@ -16,28 +16,29 @@ class CloudPreferences:
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self._prefs = None
|
||||
|
||||
async def async_initialize(self, logged_in):
|
||||
async def async_initialize(self):
|
||||
"""Finish initializing the preferences."""
|
||||
prefs = await self._store.async_load()
|
||||
|
||||
if prefs is None:
|
||||
# Backwards compat: we enable alexa/google if already logged in
|
||||
prefs = {
|
||||
PREF_ENABLE_ALEXA: logged_in,
|
||||
PREF_ENABLE_GOOGLE: logged_in,
|
||||
PREF_ENABLE_ALEXA: True,
|
||||
PREF_ENABLE_GOOGLE: True,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK: False,
|
||||
PREF_CLOUDHOOKS: {}
|
||||
}
|
||||
await self._store.async_save(prefs)
|
||||
|
||||
self._prefs = prefs
|
||||
|
||||
async def async_update(self, *, google_enabled=_UNDEF,
|
||||
alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF):
|
||||
alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF,
|
||||
cloudhooks=_UNDEF):
|
||||
"""Update user preferences."""
|
||||
for key, value in (
|
||||
(PREF_ENABLE_GOOGLE, google_enabled),
|
||||
(PREF_ENABLE_ALEXA, alexa_enabled),
|
||||
(PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock),
|
||||
(PREF_CLOUDHOOKS, cloudhooks),
|
||||
):
|
||||
if value is not _UNDEF:
|
||||
self._prefs[key] = value
|
||||
@@ -62,3 +63,8 @@ class CloudPreferences:
|
||||
def google_allow_unlock(self):
|
||||
"""Return if Google is allowed to unlock locks."""
|
||||
return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False)
|
||||
|
||||
@property
|
||||
def cloudhooks(self):
|
||||
"""Return the published cloud webhooks."""
|
||||
return self._prefs.get(PREF_CLOUDHOOKS, {})
|
||||
|
||||
@@ -14,6 +14,8 @@ from homeassistant.util.yaml import load_yaml, dump
|
||||
DOMAIN = 'config'
|
||||
DEPENDENCIES = ['http']
|
||||
SECTIONS = (
|
||||
'auth',
|
||||
'auth_provider_homeassistant',
|
||||
'automation',
|
||||
'config_entries',
|
||||
'core',
|
||||
@@ -58,10 +60,6 @@ async def async_setup(hass, config):
|
||||
|
||||
tasks = [setup_panel(panel_name) for panel_name in SECTIONS]
|
||||
|
||||
if hass.auth.active:
|
||||
tasks.append(setup_panel('auth'))
|
||||
tasks.append(setup_panel('auth_provider_homeassistant'))
|
||||
|
||||
for panel_name in ON_DEMAND:
|
||||
if panel_name in hass.config.components:
|
||||
tasks.append(setup_panel(panel_name))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user