mirror of
https://github.com/home-assistant/core.git
synced 2026-01-04 14:55:39 +01:00
Compare commits
584 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6603b3eccd | ||
|
|
e2bf3ac095 | ||
|
|
ced96775fe | ||
|
|
f65e57bf7b | ||
|
|
88cda043ac | ||
|
|
404fbe388c | ||
|
|
a0bc96c20d | ||
|
|
e98476e026 | ||
|
|
aa45ff83bd | ||
|
|
029d006beb | ||
|
|
e94eb686a6 | ||
|
|
2da5a02285 | ||
|
|
e3b1008511 | ||
|
|
cb874fefbb | ||
|
|
0454a5fa3f | ||
|
|
d8f6331318 | ||
|
|
d7459c73e0 | ||
|
|
fa9fe4067a | ||
|
|
55aaa894c3 | ||
|
|
18bc772cbb | ||
|
|
a5072f0fe4 | ||
|
|
76c26da4cb | ||
|
|
3528d865b7 | ||
|
|
e6c224fa40 | ||
|
|
048f219a7f | ||
|
|
945b84a7df | ||
|
|
393ada0312 | ||
|
|
da160066c3 | ||
|
|
ff9427d463 | ||
|
|
3eb646eb0d | ||
|
|
578fe371c6 | ||
|
|
1b03a35fa1 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
c6c55c4419 | ||
|
|
1364114dc1 | ||
|
|
a306475065 | ||
|
|
faeaa43393 | ||
|
|
aadf72d445 | ||
|
|
05915775e3 | ||
|
|
311c796da7 | ||
|
|
f860cac4ea | ||
|
|
58e0ff0b1b | ||
|
|
48e28843e6 | ||
|
|
e06fa0d2d0 | ||
|
|
0bdf96d94c | ||
|
|
623cec206b | ||
|
|
a2386f871d | ||
|
|
5c3a4e3d10 | ||
|
|
a039c3209b | ||
|
|
fc8b1f4968 | ||
|
|
052d305243 | ||
|
|
43676fcaf4 | ||
|
|
f3047b9c03 | ||
|
|
775c909a8c | ||
|
|
3a8303137a | ||
|
|
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 | ||
|
|
f9f71c4a6d | ||
|
|
c3b76b40f6 | ||
|
|
56c7c8ccc5 | ||
|
|
bb75a39cf1 | ||
|
|
2f581b1a1e | ||
|
|
cf22060c5e | ||
|
|
6bcedb3ac5 | ||
|
|
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 | ||
|
|
fd7fff2ce8 | ||
|
|
4e58eb8bae | ||
|
|
49121f2347 | ||
|
|
708ababd78 | ||
|
|
92c0f9e4aa | ||
|
|
81cac33801 | ||
|
|
1e3930a447 | ||
|
|
3cde8dc3a9 | ||
|
|
36c31a6293 | ||
|
|
8aa2cefd75 | ||
|
|
3b53003795 | ||
|
|
377730a37c | ||
|
|
d9c7f777c5 | ||
|
|
b7742999cf | ||
|
|
8742750926 | ||
|
|
e87ecbd500 | ||
|
|
3838be4cb8 | ||
|
|
0ddd502d00 | ||
|
|
d88040eeed | ||
|
|
44b33d45b1 | ||
|
|
9b53b7e9e4 | ||
|
|
80cd8b180c | ||
|
|
b3e37af9b1 | ||
|
|
57f7e7eedc | ||
|
|
14ad7428ea | ||
|
|
f86083cf52 | ||
|
|
3891f2eebe | ||
|
|
de9bac9ee3 | ||
|
|
01953ab46b | ||
|
|
fc4dd4e51f | ||
|
|
90f3f2b1e7 | ||
|
|
c1ca7beea1 | ||
|
|
84fd66c8a1 | ||
|
|
97c493448b | ||
|
|
9fa34f0d77 | ||
|
|
cdcc818bf9 | ||
|
|
83b4e56978 | ||
|
|
089a2f4e71 | ||
|
|
f241becf7f | ||
|
|
7e702d3caa | ||
|
|
ab8c127a4a | ||
|
|
afe21b4408 | ||
|
|
b066877453 | ||
|
|
796933de68 | ||
|
|
8f59be2059 | ||
|
|
dfb8f60fe2 | ||
|
|
3f747f1a8c | ||
|
|
4751ad69a7 | ||
|
|
c6ca27e9b4 | ||
|
|
e73b9b9b8f | ||
|
|
6b2f50b29e | ||
|
|
fcd756d58a | ||
|
|
24db2b66ab | ||
|
|
320efdb744 | ||
|
|
9e0497875e | ||
|
|
30806fa362 | ||
|
|
9f51deb1de | ||
|
|
0ca94f239d | ||
|
|
ed7aea006a | ||
|
|
b7b8296c73 | ||
|
|
afb3a52b5b | ||
|
|
4446b15cb0 | ||
|
|
d1b5bc19da | ||
|
|
75bb78d440 | ||
|
|
2d870a29c4 | ||
|
|
7cb7c76a83 | ||
|
|
b40b934029 | ||
|
|
5ffcb99b4f | ||
|
|
69d358fa08 | ||
|
|
f36b94b376 | ||
|
|
b5d4e18880 | ||
|
|
43271ca0f7 | ||
|
|
7659c33439 | ||
|
|
b8ddbc3fdb | ||
|
|
2aa2233d9b | ||
|
|
6bcba1fbea | ||
|
|
466d3a5ef8 | ||
|
|
8aa1283adc | ||
|
|
312872961f | ||
|
|
00235cf6f0 | ||
|
|
80e616cacf | ||
|
|
c7ac216602 | ||
|
|
0d43cb6d0e | ||
|
|
d2e102ee2f | ||
|
|
d2907b8e53 | ||
|
|
419400f90b | ||
|
|
532a75b487 | ||
|
|
291fba0ba4 | ||
|
|
597da90622 | ||
|
|
f14251bdcc | ||
|
|
ebdfb56803 | ||
|
|
7aa41d66e9 | ||
|
|
7113ec6073 | ||
|
|
f78dcb96b0 | ||
|
|
996da72a4c | ||
|
|
8547489014 | ||
|
|
c6683cba7d | ||
|
|
ea4480f170 | ||
|
|
0ab81b03a8 | ||
|
|
d0463942be | ||
|
|
275b485b36 | ||
|
|
15c77fe548 | ||
|
|
e5930da972 | ||
|
|
8fb6030f97 | ||
|
|
9eac11dcbe | ||
|
|
afd9c44ffb | ||
|
|
1f06d6ac1a | ||
|
|
ca86755409 | ||
|
|
1f476936a2 | ||
|
|
ddeeba20b9 | ||
|
|
5129a48750 | ||
|
|
95eae47438 | ||
|
|
372470f52a | ||
|
|
02cc6a2f9a | ||
|
|
9cb6464c58 | ||
|
|
5b9a9d8e04 | ||
|
|
9411fca955 | ||
|
|
b8c06ad019 | ||
|
|
9c92151ad1 | ||
|
|
f0a0ce504b | ||
|
|
d9533127f9 | ||
|
|
fa127188df | ||
|
|
667b41dd4a | ||
|
|
f236e14bd6 | ||
|
|
e75f9b36f9 | ||
|
|
132bb7902a | ||
|
|
df2ab62ce9 | ||
|
|
f7c99ada9d | ||
|
|
8bd281d5a3 | ||
|
|
210eab16da | ||
|
|
64ada1ea5a | ||
|
|
14ad5c0006 | ||
|
|
d34c47a9e1 | ||
|
|
f971309113 | ||
|
|
f8ca4cfd91 | ||
|
|
4324d87673 | ||
|
|
7f48a280ee | ||
|
|
f4c35a389d | ||
|
|
8ab2f669d2 | ||
|
|
de37fc90c0 | ||
|
|
c571637176 | ||
|
|
b803075eb4 | ||
|
|
ae85baf396 | ||
|
|
05eac915d1 | ||
|
|
8f107c46fe | ||
|
|
fd2987e551 | ||
|
|
9472529d43 | ||
|
|
f7f0a4e811 | ||
|
|
54b0cde52a | ||
|
|
a016dd2140 | ||
|
|
878e369c4a | ||
|
|
599542394a | ||
|
|
7fed49c4ab | ||
|
|
954191c385 | ||
|
|
e2fca0691e | ||
|
|
f24979c7cf | ||
|
|
5bab0018f5 | ||
|
|
f541b101c9 | ||
|
|
0bf054fb59 | ||
|
|
aa4da479b5 | ||
|
|
d93716bd84 | ||
|
|
f99701f41a | ||
|
|
29be78e08e | ||
|
|
ec732c896d | ||
|
|
00c1b40940 | ||
|
|
4287d1dd2d | ||
|
|
5cee9942a6 | ||
|
|
65be458ce0 | ||
|
|
ce069be16e | ||
|
|
0d7cb54872 | ||
|
|
6935b62487 | ||
|
|
e698fc2553 | ||
|
|
df3d82e0e3 | ||
|
|
35ae85e14e | ||
|
|
c89dade619 | ||
|
|
bdba3852d0 | ||
|
|
c41ca37a04 | ||
|
|
917ebed4c9 | ||
|
|
43ae57cc59 | ||
|
|
f4d3d5904e | ||
|
|
bde02afe4f | ||
|
|
42fea4fb97 | ||
|
|
52074ee9bb | ||
|
|
eb385515c8 | ||
|
|
589764900a | ||
|
|
9329ec2486 | ||
|
|
47af194d06 | ||
|
|
39412dc930 | ||
|
|
2e517ab6bc | ||
|
|
7933bd7f91 | ||
|
|
58c77e1f55 | ||
|
|
e3a8f3a106 | ||
|
|
1aba4699b9 | ||
|
|
114bc8ec18 | ||
|
|
c6f3c239bb | ||
|
|
34d7758b4a | ||
|
|
2c36b9db1f | ||
|
|
24efda20bf | ||
|
|
3322fee814 | ||
|
|
4581a741bd | ||
|
|
b506aafbb4 | ||
|
|
121ec5c684 | ||
|
|
087bffeaae | ||
|
|
2bf2214d51 | ||
|
|
ddee5f8b86 | ||
|
|
c5d0440041 | ||
|
|
24c110ad3c | ||
|
|
7077e19cf8 | ||
|
|
6f568d1cf6 | ||
|
|
d951ed4d68 | ||
|
|
3366d2c1ad | ||
|
|
46b5b6240f | ||
|
|
abf147ed57 | ||
|
|
c59b038512 | ||
|
|
93b16e7efb | ||
|
|
561f6996c6 | ||
|
|
b261c4b7f8 | ||
|
|
26ba4a56e8 | ||
|
|
dcdae325ea | ||
|
|
3d4ff74761 | ||
|
|
81fa74e5ca | ||
|
|
f9f53fd278 | ||
|
|
36524e9d3f | ||
|
|
bf54582d76 | ||
|
|
8de79ed57c | ||
|
|
8ee0e0c6c6 | ||
|
|
a901c594a9 | ||
|
|
2e9132873a | ||
|
|
6e4ce35a69 | ||
|
|
1c3ef8be55 | ||
|
|
922f34f72d | ||
|
|
959fa81ea6 | ||
|
|
9a6c229b1d | ||
|
|
4a7507bcea | ||
|
|
44556a86e3 | ||
|
|
e161dc3b77 | ||
|
|
dbf721cd2c | ||
|
|
0992e83f8d | ||
|
|
a498e15910 | ||
|
|
27e159f63f | ||
|
|
7b53238f9b | ||
|
|
075169a7a9 | ||
|
|
3abe49bace | ||
|
|
eb0d989c88 | ||
|
|
42cb23f768 | ||
|
|
5418e0510d | ||
|
|
164c68093b | ||
|
|
5dd691e55d | ||
|
|
155df912e5 | ||
|
|
610b0b6494 | ||
|
|
f76ccb636c | ||
|
|
fbcf0880f3 |
43
.coveragerc
43
.coveragerc
@@ -120,6 +120,9 @@ omit =
|
||||
homeassistant/components/eufy.py
|
||||
homeassistant/components/*/eufy.py
|
||||
|
||||
homeassistant/components/fibaro.py
|
||||
homeassistant/components/*/fibaro.py
|
||||
|
||||
homeassistant/components/gc100.py
|
||||
homeassistant/components/*/gc100.py
|
||||
|
||||
@@ -145,6 +148,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
|
||||
|
||||
@@ -200,9 +206,15 @@ omit =
|
||||
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
|
||||
|
||||
homeassistant/components/lupusec.py
|
||||
homeassistant/components/*/lupusec.py
|
||||
|
||||
homeassistant/components/lutron.py
|
||||
homeassistant/components/*/lutron.py
|
||||
|
||||
@@ -256,6 +268,10 @@ omit =
|
||||
homeassistant/components/pilight.py
|
||||
homeassistant/components/*/pilight.py
|
||||
|
||||
homeassistant/components/point/__init__.py
|
||||
homeassistant/components/point/const.py
|
||||
homeassistant/components/*/point.py
|
||||
|
||||
homeassistant/components/switch/qwikswitch.py
|
||||
homeassistant/components/light/qwikswitch.py
|
||||
|
||||
@@ -265,7 +281,7 @@ omit =
|
||||
homeassistant/components/raincloud.py
|
||||
homeassistant/components/*/raincloud.py
|
||||
|
||||
homeassistant/components/rainmachine/*
|
||||
homeassistant/components/rainmachine/__init__.py
|
||||
homeassistant/components/*/rainmachine.py
|
||||
|
||||
homeassistant/components/raspihats.py
|
||||
@@ -313,7 +329,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
|
||||
@@ -333,6 +350,9 @@ omit =
|
||||
homeassistant/components/toon.py
|
||||
homeassistant/components/*/toon.py
|
||||
|
||||
homeassistant/components/tplink_lte.py
|
||||
homeassistant/components/*/tplink_lte.py
|
||||
|
||||
homeassistant/components/tradfri.py
|
||||
homeassistant/components/*/tradfri.py
|
||||
|
||||
@@ -365,6 +385,9 @@ omit =
|
||||
|
||||
homeassistant/components/*/webostv.py
|
||||
|
||||
homeassistant/components/w800rf32.py
|
||||
homeassistant/components/*/w800rf32.py
|
||||
|
||||
homeassistant/components/wemo.py
|
||||
homeassistant/components/*/wemo.py
|
||||
|
||||
@@ -384,6 +407,8 @@ omit =
|
||||
|
||||
homeassistant/components/zha/__init__.py
|
||||
homeassistant/components/zha/const.py
|
||||
homeassistant/components/zha/entities/*
|
||||
homeassistant/components/zha/helpers.py
|
||||
homeassistant/components/*/zha.py
|
||||
|
||||
homeassistant/components/zigbee.py
|
||||
@@ -474,6 +499,7 @@ omit =
|
||||
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
|
||||
homeassistant/components/device_tracker/gpslogger.py
|
||||
homeassistant/components/device_tracker/hitron_coda.py
|
||||
homeassistant/components/device_tracker/huawei_router.py
|
||||
@@ -496,6 +522,7 @@ omit =
|
||||
homeassistant/components/device_tracker/tile.py
|
||||
homeassistant/components/device_tracker/tomato.py
|
||||
homeassistant/components/device_tracker/tplink.py
|
||||
homeassistant/components/device_tracker/traccar.py
|
||||
homeassistant/components/device_tracker/trackr.py
|
||||
homeassistant/components/device_tracker/ubus.py
|
||||
homeassistant/components/downloader.py
|
||||
@@ -530,6 +557,7 @@ omit =
|
||||
homeassistant/components/light/lw12wifi.py
|
||||
homeassistant/components/light/mystrom.py
|
||||
homeassistant/components/light/nanoleaf_aurora.py
|
||||
homeassistant/components/light/niko_home_control.py
|
||||
homeassistant/components/light/opple.py
|
||||
homeassistant/components/light/osramlightify.py
|
||||
homeassistant/components/light/piglow.py
|
||||
@@ -572,7 +600,7 @@ omit =
|
||||
homeassistant/components/media_player/itunes.py
|
||||
homeassistant/components/media_player/kodi.py
|
||||
homeassistant/components/media_player/lg_netcast.py
|
||||
homeassistant/components/media_player/lg_soundbar.py
|
||||
homeassistant/components/media_player/lg_soundbar.py
|
||||
homeassistant/components/media_player/liveboxplaytv.py
|
||||
homeassistant/components/media_player/mediaroom.py
|
||||
homeassistant/components/media_player/mpchc.py
|
||||
@@ -581,6 +609,7 @@ omit =
|
||||
homeassistant/components/media_player/nadtcp.py
|
||||
homeassistant/components/media_player/onkyo.py
|
||||
homeassistant/components/media_player/openhome.py
|
||||
homeassistant/components/media_player/panasonic_bluray.py
|
||||
homeassistant/components/media_player/panasonic_viera.py
|
||||
homeassistant/components/media_player/pandora.py
|
||||
homeassistant/components/media_player/philips_js.py
|
||||
@@ -617,7 +646,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
|
||||
@@ -696,6 +724,7 @@ omit =
|
||||
homeassistant/components/sensor/fints.py
|
||||
homeassistant/components/sensor/fitbit.py
|
||||
homeassistant/components/sensor/fixer.py
|
||||
homeassistant/components/sensor/flunearyou.py
|
||||
homeassistant/components/sensor/folder.py
|
||||
homeassistant/components/sensor/foobot.py
|
||||
homeassistant/components/sensor/fritzbox_callmonitor.py
|
||||
@@ -720,6 +749,7 @@ omit =
|
||||
homeassistant/components/sensor/kwb.py
|
||||
homeassistant/components/sensor/lacrosse.py
|
||||
homeassistant/components/sensor/lastfm.py
|
||||
homeassistant/components/sensor/launch_library.py
|
||||
homeassistant/components/sensor/linky.py
|
||||
homeassistant/components/sensor/linux_battery.py
|
||||
homeassistant/components/sensor/loopenergy.py
|
||||
@@ -758,15 +788,18 @@ omit =
|
||||
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
|
||||
homeassistant/components/sensor/ripple.py
|
||||
homeassistant/components/sensor/rtorrent.py
|
||||
homeassistant/components/sensor/ruter.py
|
||||
homeassistant/components/sensor/scrape.py
|
||||
homeassistant/components/sensor/sensehat.py
|
||||
homeassistant/components/sensor/serial_pm.py
|
||||
homeassistant/components/sensor/serial.py
|
||||
homeassistant/components/sensor/seventeentrack.py
|
||||
homeassistant/components/sensor/sht31.py
|
||||
homeassistant/components/sensor/shodan.py
|
||||
homeassistant/components/sensor/sigfox.py
|
||||
@@ -786,9 +819,11 @@ omit =
|
||||
homeassistant/components/sensor/swiss_public_transport.py
|
||||
homeassistant/components/sensor/syncthru.py
|
||||
homeassistant/components/sensor/synologydsm.py
|
||||
homeassistant/components/sensor/srp_energy.py
|
||||
homeassistant/components/sensor/systemmonitor.py
|
||||
homeassistant/components/sensor/sytadin.py
|
||||
homeassistant/components/sensor/tank_utility.py
|
||||
homeassistant/components/sensor/tautulli.py
|
||||
homeassistant/components/sensor/ted5000.py
|
||||
homeassistant/components/sensor/temper.py
|
||||
homeassistant/components/sensor/thermoworks_smoke.py
|
||||
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -13,6 +13,7 @@
|
||||
## Checklist:
|
||||
- [ ] The code change is tested and works locally.
|
||||
- [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass**
|
||||
- [ ] There is no commented out code in this PR.
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
- [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io)
|
||||
|
||||
24
CODEOWNERS
24
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
|
||||
@@ -102,14 +105,15 @@ homeassistant/components/sensor/darksky.py @fabaff
|
||||
homeassistant/components/sensor/file.py @fabaff
|
||||
homeassistant/components/sensor/filter.py @dgomes
|
||||
homeassistant/components/sensor/fixer.py @fabaff
|
||||
homeassistant/components/sensor/flunearyou.py.py @bachya
|
||||
homeassistant/components/sensor/gearbest.py @HerrHofrat
|
||||
homeassistant/components/sensor/gitter.py @fabaff
|
||||
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/luftdaten.py @fabaff
|
||||
homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel
|
||||
homeassistant/components/sensor/min_max.py @fabaff
|
||||
homeassistant/components/sensor/moon.py @fabaff
|
||||
@@ -119,20 +123,25 @@ 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
|
||||
homeassistant/components/sensor/shodan.py @fabaff
|
||||
homeassistant/components/sensor/sma.py @kellerza
|
||||
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
|
||||
@@ -156,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
|
||||
@@ -189,6 +201,8 @@ homeassistant/components/*/konnected.py @heythisisnate
|
||||
# L
|
||||
homeassistant/components/lifx.py @amelchio
|
||||
homeassistant/components/*/lifx.py @amelchio
|
||||
homeassistant/components/luftdaten/* @fabaff
|
||||
homeassistant/components/*/luftdaten.py @fabaff
|
||||
|
||||
# M
|
||||
homeassistant/components/matrix.py @tinloaf
|
||||
@@ -201,6 +215,10 @@ homeassistant/components/*/mystrom.py @fabaff
|
||||
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
|
||||
@@ -218,8 +236,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
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import auth_store, models
|
||||
from .const import GROUP_ID_ADMIN
|
||||
from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule
|
||||
from .providers import auth_provider_from_config, AuthProvider, LoginFlow
|
||||
|
||||
@@ -77,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:
|
||||
"""
|
||||
@@ -117,6 +113,10 @@ class AuthManager:
|
||||
"""Retrieve a user."""
|
||||
return await self._store.async_get_user(user_id)
|
||||
|
||||
async def async_get_group(self, group_id: str) -> Optional[models.Group]:
|
||||
"""Retrieve all groups."""
|
||||
return await self._store.async_get_group(group_id)
|
||||
|
||||
async def async_get_user_by_credentials(
|
||||
self, credentials: models.Credentials) -> Optional[models.User]:
|
||||
"""Get a user by credential, return None if not found."""
|
||||
@@ -127,13 +127,15 @@ class AuthManager:
|
||||
|
||||
return None
|
||||
|
||||
async def async_create_system_user(self, name: str) -> models.User:
|
||||
async def async_create_system_user(
|
||||
self, name: str,
|
||||
group_ids: Optional[List[str]] = None) -> models.User:
|
||||
"""Create a system user."""
|
||||
user = await self._store.async_create_user(
|
||||
name=name,
|
||||
system_generated=True,
|
||||
is_active=True,
|
||||
groups=[],
|
||||
group_ids=group_ids or [],
|
||||
)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_USER_ADDED, {
|
||||
@@ -144,11 +146,10 @@ class AuthManager:
|
||||
|
||||
async def async_create_user(self, name: str) -> models.User:
|
||||
"""Create a user."""
|
||||
group = (await self._store.async_get_groups())[0]
|
||||
kwargs = {
|
||||
'name': name,
|
||||
'is_active': True,
|
||||
'groups': [group]
|
||||
'group_ids': [GROUP_ID_ADMIN]
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if await self._user_should_be_owner():
|
||||
@@ -184,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, {
|
||||
@@ -213,6 +215,17 @@ class AuthManager:
|
||||
'user_id': user.id
|
||||
})
|
||||
|
||||
async def async_update_user(self, user: models.User,
|
||||
name: Optional[str] = None,
|
||||
group_ids: Optional[List[str]] = None) -> None:
|
||||
"""Update a user."""
|
||||
kwargs = {} # type: Dict[str,Any]
|
||||
if name is not None:
|
||||
kwargs['name'] = name
|
||||
if group_ids is not None:
|
||||
kwargs['group_ids'] = group_ids
|
||||
await self._store.async_update_user(user, **kwargs)
|
||||
|
||||
async def async_activate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
await self._store.async_activate_user(user)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Storage for auth models."""
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
import hmac
|
||||
@@ -10,11 +11,14 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import models
|
||||
from .permissions import DEFAULT_POLICY
|
||||
from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
|
||||
from .permissions import PermissionLookup, system_policies
|
||||
from .permissions.types import PolicyType # noqa: F401
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = 'auth'
|
||||
INITIAL_GROUP_NAME = 'All Access'
|
||||
GROUP_NAME_ADMIN = 'Administrators'
|
||||
GROUP_NAME_READ_ONLY = 'Read Only'
|
||||
|
||||
|
||||
class AuthStore:
|
||||
@@ -31,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)
|
||||
|
||||
@@ -42,6 +47,14 @@ class AuthStore:
|
||||
|
||||
return list(self._groups.values())
|
||||
|
||||
async def async_get_group(self, group_id: str) -> Optional[models.Group]:
|
||||
"""Retrieve all users."""
|
||||
if self._groups is None:
|
||||
await self._async_load()
|
||||
assert self._groups is not None
|
||||
|
||||
return self._groups.get(group_id)
|
||||
|
||||
async def async_get_users(self) -> List[models.User]:
|
||||
"""Retrieve all users."""
|
||||
if self._users is None:
|
||||
@@ -63,7 +76,7 @@ class AuthStore:
|
||||
is_active: Optional[bool] = None,
|
||||
system_generated: Optional[bool] = None,
|
||||
credentials: Optional[models.Credentials] = None,
|
||||
groups: Optional[List[models.Group]] = None) -> models.User:
|
||||
group_ids: Optional[List[str]] = None) -> models.User:
|
||||
"""Create a new user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
@@ -71,11 +84,19 @@ class AuthStore:
|
||||
assert self._users is not None
|
||||
assert self._groups is not None
|
||||
|
||||
groups = []
|
||||
for group_id in (group_ids or []):
|
||||
group = self._groups.get(group_id)
|
||||
if group is None:
|
||||
raise ValueError('Invalid group specified {}'.format(group_id))
|
||||
groups.append(group)
|
||||
|
||||
kwargs = {
|
||||
'name': name,
|
||||
# Until we get group management, we just put everyone in the
|
||||
# same group.
|
||||
'groups': groups or [],
|
||||
'groups': groups,
|
||||
'perm_lookup': self._perm_lookup,
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if is_owner is not None:
|
||||
@@ -115,6 +136,33 @@ class AuthStore:
|
||||
self._users.pop(user.id)
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_update_user(
|
||||
self, user: models.User, name: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
group_ids: Optional[List[str]] = None) -> None:
|
||||
"""Update a user."""
|
||||
assert self._groups is not None
|
||||
|
||||
if group_ids is not None:
|
||||
groups = []
|
||||
for grid in group_ids:
|
||||
group = self._groups.get(grid)
|
||||
if group is None:
|
||||
raise ValueError("Invalid group specified.")
|
||||
groups.append(group)
|
||||
|
||||
user.groups = groups
|
||||
user.invalidate_permission_cache()
|
||||
|
||||
for attr_name, value in (
|
||||
('name', name),
|
||||
('is_active', is_active),
|
||||
):
|
||||
if value is not None:
|
||||
setattr(user, attr_name, value)
|
||||
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_activate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
user.is_active = True
|
||||
@@ -224,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
|
||||
@@ -238,38 +291,99 @@ class AuthStore:
|
||||
users = OrderedDict() # type: Dict[str, models.User]
|
||||
groups = OrderedDict() # type: Dict[str, models.Group]
|
||||
|
||||
# When creating objects we mention each attribute explicetely. This
|
||||
# Soft-migrating data as we load. We are going to make sure we have a
|
||||
# read only group and an admin group. There are two states that we can
|
||||
# migrate from:
|
||||
# 1. Data from a recent version which has a single group without policy
|
||||
# 2. Data from old version which has no groups
|
||||
has_admin_group = False
|
||||
has_read_only_group = False
|
||||
group_without_policy = None
|
||||
|
||||
# When creating objects we mention each attribute explicitly. This
|
||||
# prevents crashing if user rolls back HA version after a new property
|
||||
# was added.
|
||||
|
||||
for group_dict in data.get('groups', []):
|
||||
policy = None # type: Optional[PolicyType]
|
||||
|
||||
if group_dict['id'] == GROUP_ID_ADMIN:
|
||||
has_admin_group = True
|
||||
|
||||
name = GROUP_NAME_ADMIN
|
||||
policy = system_policies.ADMIN_POLICY
|
||||
system_generated = True
|
||||
|
||||
elif group_dict['id'] == GROUP_ID_READ_ONLY:
|
||||
has_read_only_group = True
|
||||
|
||||
name = GROUP_NAME_READ_ONLY
|
||||
policy = system_policies.READ_ONLY_POLICY
|
||||
system_generated = True
|
||||
|
||||
else:
|
||||
name = group_dict['name']
|
||||
policy = group_dict.get('policy')
|
||||
system_generated = False
|
||||
|
||||
# We don't want groups without a policy that are not system groups
|
||||
# This is part of migrating from state 1
|
||||
if policy is None:
|
||||
group_without_policy = group_dict['id']
|
||||
continue
|
||||
|
||||
groups[group_dict['id']] = models.Group(
|
||||
name=group_dict['name'],
|
||||
id=group_dict['id'],
|
||||
policy=group_dict.get('policy', DEFAULT_POLICY),
|
||||
name=name,
|
||||
policy=policy,
|
||||
system_generated=system_generated,
|
||||
)
|
||||
|
||||
migrate_group = None
|
||||
# If there are no groups, add all existing users to the admin group.
|
||||
# This is part of migrating from state 2
|
||||
migrate_users_to_admin_group = (not groups and
|
||||
group_without_policy is None)
|
||||
|
||||
if not groups:
|
||||
migrate_group = models.Group(
|
||||
name=INITIAL_GROUP_NAME,
|
||||
policy=DEFAULT_POLICY
|
||||
)
|
||||
groups[migrate_group.id] = migrate_group
|
||||
# If we find a no_policy_group, we need to migrate all users to the
|
||||
# admin group. We only do this if there are no other groups, as is
|
||||
# the expected state. If not expected state, not marking people admin.
|
||||
# This is part of migrating from state 1
|
||||
if groups and group_without_policy is not None:
|
||||
group_without_policy = None
|
||||
|
||||
# This is part of migrating from state 1 and 2
|
||||
if not has_admin_group:
|
||||
admin_group = _system_admin_group()
|
||||
groups[admin_group.id] = admin_group
|
||||
|
||||
# This is part of migrating from state 1 and 2
|
||||
if not has_read_only_group:
|
||||
read_only_group = _system_read_only_group()
|
||||
groups[read_only_group.id] = read_only_group
|
||||
|
||||
for user_dict in data['users']:
|
||||
# Collect the users group.
|
||||
user_groups = []
|
||||
for group_id in user_dict.get('group_ids', []):
|
||||
# This is part of migrating from state 1
|
||||
if group_id == group_without_policy:
|
||||
group_id = GROUP_ID_ADMIN
|
||||
user_groups.append(groups[group_id])
|
||||
|
||||
# This is part of migrating from state 2
|
||||
if (not user_dict['system_generated'] and
|
||||
migrate_users_to_admin_group):
|
||||
user_groups.append(groups[GROUP_ID_ADMIN])
|
||||
|
||||
users[user_dict['id']] = models.User(
|
||||
name=user_dict['name'],
|
||||
groups=[groups[group_id] for group_id
|
||||
in user_dict.get('group_ids', [])],
|
||||
groups=user_groups,
|
||||
id=user_dict['id'],
|
||||
is_owner=user_dict['is_owner'],
|
||||
is_active=user_dict['is_active'],
|
||||
system_generated=user_dict['system_generated'],
|
||||
perm_lookup=perm_lookup,
|
||||
)
|
||||
if migrate_group is not None and not user_dict['system_generated']:
|
||||
users[user_dict['id']].groups = [migrate_group]
|
||||
|
||||
for cred_dict in data['credentials']:
|
||||
users[cred_dict['user_id']].credentials.append(models.Credentials(
|
||||
@@ -356,11 +470,12 @@ class AuthStore:
|
||||
groups = []
|
||||
for group in self._groups.values():
|
||||
g_dict = {
|
||||
'name': group.name,
|
||||
'id': group.id,
|
||||
# Name not read for sys groups. Kept here for backwards compat
|
||||
'name': group.name
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if group.policy is not DEFAULT_POLICY:
|
||||
if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN):
|
||||
g_dict['policy'] = group.policy
|
||||
|
||||
groups.append(g_dict)
|
||||
@@ -410,13 +525,29 @@ class AuthStore:
|
||||
"""Set default values for auth store."""
|
||||
self._users = OrderedDict() # type: Dict[str, models.User]
|
||||
|
||||
# Add default group
|
||||
all_access_group = models.Group(
|
||||
name=INITIAL_GROUP_NAME,
|
||||
policy=DEFAULT_POLICY,
|
||||
)
|
||||
|
||||
groups = OrderedDict() # type: Dict[str, models.Group]
|
||||
groups[all_access_group.id] = all_access_group
|
||||
|
||||
admin_group = _system_admin_group()
|
||||
groups[admin_group.id] = admin_group
|
||||
read_only_group = _system_read_only_group()
|
||||
groups[read_only_group.id] = read_only_group
|
||||
self._groups = groups
|
||||
|
||||
|
||||
def _system_admin_group() -> models.Group:
|
||||
"""Create system admin group."""
|
||||
return models.Group(
|
||||
name=GROUP_NAME_ADMIN,
|
||||
id=GROUP_ID_ADMIN,
|
||||
policy=system_policies.ADMIN_POLICY,
|
||||
system_generated=True,
|
||||
)
|
||||
|
||||
|
||||
def _system_read_only_group() -> models.Group:
|
||||
"""Create read only group."""
|
||||
return models.Group(
|
||||
name=GROUP_NAME_READ_ONLY,
|
||||
id=GROUP_ID_READ_ONLY,
|
||||
policy=system_policies.READ_ONLY_POLICY,
|
||||
system_generated=True,
|
||||
)
|
||||
|
||||
@@ -3,3 +3,6 @@ from datetime import timedelta
|
||||
|
||||
ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
|
||||
MFA_SESSION_EXPIRATION = timedelta(minutes=5)
|
||||
|
||||
GROUP_ID_ADMIN = 'system-admin'
|
||||
GROUP_ID_READ_ONLY = 'system-read-only'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,6 +8,7 @@ import attr
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import permissions as perm_mdl
|
||||
from .const import GROUP_ID_ADMIN
|
||||
from .util import generate_secret
|
||||
|
||||
TOKEN_TYPE_NORMAL = 'normal'
|
||||
@@ -22,6 +23,7 @@ class Group:
|
||||
name = attr.ib(type=str) # type: Optional[str]
|
||||
policy = attr.ib(type=perm_mdl.PolicyType)
|
||||
id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
|
||||
system_generated = attr.ib(type=bool, default=False)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
@@ -29,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)
|
||||
@@ -47,7 +52,7 @@ class User:
|
||||
) # type: Dict[str, RefreshToken]
|
||||
|
||||
_permissions = attr.ib(
|
||||
type=perm_mdl.PolicyPermissions,
|
||||
type=Optional[perm_mdl.PolicyPermissions],
|
||||
init=False,
|
||||
cmp=False,
|
||||
default=None,
|
||||
@@ -64,10 +69,24 @@ 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
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Return if user is part of the admin group."""
|
||||
if self.is_owner:
|
||||
return True
|
||||
|
||||
return self.is_active and any(
|
||||
gr.id == GROUP_ID_ADMIN for gr in self.groups)
|
||||
|
||||
def invalidate_permission_cache(self) -> None:
|
||||
"""Invalidate permission cache."""
|
||||
self._permissions = None
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class RefreshToken:
|
||||
|
||||
@@ -1,24 +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 homeassistant.core import State
|
||||
|
||||
from .common import CategoryType, PolicyType
|
||||
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
|
||||
|
||||
|
||||
# Default policy if group has no policy applied.
|
||||
DEFAULT_POLICY = {
|
||||
"entities": True
|
||||
} # type: PolicyType
|
||||
|
||||
CAT_ENTITIES = 'entities'
|
||||
|
||||
POLICY_SCHEMA = vol.Schema({
|
||||
vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA
|
||||
})
|
||||
@@ -29,49 +23,35 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AbstractPermissions:
|
||||
"""Default permissions class."""
|
||||
|
||||
def check_entity(self, entity_id: str, key: str) -> bool:
|
||||
"""Test if we can access entity."""
|
||||
_cached_entity_func = None
|
||||
|
||||
def _entity_func(self) -> Callable[[str, str], bool]:
|
||||
"""Return a function that can test entity access."""
|
||||
raise NotImplementedError
|
||||
|
||||
def filter_states(self, states: List[State]) -> List[State]:
|
||||
"""Filter a list of states for what the user is allowed to see."""
|
||||
raise NotImplementedError
|
||||
def check_entity(self, entity_id: str, key: str) -> bool:
|
||||
"""Check if we can access entity."""
|
||||
entity_func = self._cached_entity_func
|
||||
|
||||
if entity_func is None:
|
||||
entity_func = self._cached_entity_func = self._entity_func()
|
||||
|
||||
return entity_func(entity_id, key)
|
||||
|
||||
|
||||
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._compiled = {} # type: Dict[str, Callable[..., bool]]
|
||||
self._perm_lookup = perm_lookup
|
||||
|
||||
def check_entity(self, entity_id: str, key: str) -> bool:
|
||||
"""Test if we can access entity."""
|
||||
func = self._policy_func(CAT_ENTITIES, compile_entities)
|
||||
return func(entity_id, (key,))
|
||||
|
||||
def filter_states(self, states: List[State]) -> List[State]:
|
||||
"""Filter a list of states for what the user is allowed to see."""
|
||||
func = self._policy_func(CAT_ENTITIES, compile_entities)
|
||||
keys = ('read',)
|
||||
return [entity for entity in states if func(entity.entity_id, keys)]
|
||||
|
||||
def _policy_func(self, category: str,
|
||||
compile_func: Callable[[CategoryType], Callable]) \
|
||||
-> Callable[..., bool]:
|
||||
"""Get a policy function."""
|
||||
func = self._compiled.get(category)
|
||||
|
||||
if func:
|
||||
return func
|
||||
|
||||
func = self._compiled[category] = compile_func(
|
||||
self._policy.get(category))
|
||||
|
||||
_LOGGER.debug("Compiled %s func: %s", category, func)
|
||||
|
||||
return func
|
||||
def _entity_func(self) -> Callable[[str, str], bool]:
|
||||
"""Return a function that can test entity access."""
|
||||
return compile_entities(self._policy.get(CAT_ENTITIES),
|
||||
self._perm_lookup)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""Equals check."""
|
||||
@@ -85,13 +65,9 @@ class _OwnerPermissions(AbstractPermissions):
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
|
||||
def check_entity(self, entity_id: str, key: str) -> bool:
|
||||
"""Test if we can access entity."""
|
||||
return True
|
||||
|
||||
def filter_states(self, states: List[State]) -> List[State]:
|
||||
"""Filter a list of states for what the user is allowed to see."""
|
||||
return states
|
||||
def _entity_func(self) -> Callable[[str, str], bool]:
|
||||
"""Return a function that can test entity access."""
|
||||
return lambda entity_id, key: True
|
||||
|
||||
|
||||
OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name
|
||||
|
||||
7
homeassistant/auth/permissions/const.py
Normal file
7
homeassistant/auth/permissions/const.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Permission constants."""
|
||||
CAT_ENTITIES = 'entities'
|
||||
SUBCAT_ALL = 'all'
|
||||
|
||||
POLICY_READ = 'read'
|
||||
POLICY_CONTROL = 'control'
|
||||
POLICY_EDIT = 'edit'
|
||||
@@ -1,16 +1,12 @@
|
||||
"""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 .common import CategoryType, ValueType, SUBCAT_ALL
|
||||
|
||||
|
||||
POLICY_READ = 'read'
|
||||
POLICY_CONTROL = 'control'
|
||||
POLICY_EDIT = 'edit'
|
||||
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({
|
||||
vol.Optional(POLICY_READ): True,
|
||||
@@ -19,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({
|
||||
@@ -27,33 +24,34 @@ 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,
|
||||
}))
|
||||
|
||||
|
||||
def _entity_allowed(schema: ValueType, keys: Tuple[str]) \
|
||||
def _entity_allowed(schema: ValueType, key: str) \
|
||||
-> Union[bool, None]:
|
||||
"""Test if an entity is allowed based on the keys."""
|
||||
if schema is None or isinstance(schema, bool):
|
||||
return schema
|
||||
assert isinstance(schema, dict)
|
||||
return schema.get(keys[0])
|
||||
return schema.get(key)
|
||||
|
||||
|
||||
def compile_entities(policy: CategoryType) \
|
||||
-> Callable[[str, Tuple[str]], bool]:
|
||||
def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \
|
||||
-> Callable[[str, str], bool]:
|
||||
"""Compile policy into a function that tests policy."""
|
||||
# None, Empty Dict, False
|
||||
if not policy:
|
||||
def apply_policy_deny_all(entity_id: str, keys: Tuple[str]) -> bool:
|
||||
def apply_policy_deny_all(entity_id: str, key: str) -> bool:
|
||||
"""Decline all."""
|
||||
return False
|
||||
|
||||
return apply_policy_deny_all
|
||||
|
||||
if policy is True:
|
||||
def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool:
|
||||
def apply_policy_allow_all(entity_id: str, key: str) -> bool:
|
||||
"""Approve all."""
|
||||
return True
|
||||
|
||||
@@ -62,10 +60,11 @@ 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)
|
||||
|
||||
funcs = [] # type: List[Callable[[str, Tuple[str]], Union[None, bool]]]
|
||||
funcs = [] # type: List[Callable[[str, str], Union[None, bool]]]
|
||||
|
||||
# The order of these functions matter. The more precise are at the top.
|
||||
# If a function returns None, they cannot handle it.
|
||||
@@ -74,23 +73,46 @@ def compile_entities(policy: CategoryType) \
|
||||
# Setting entity_ids to a boolean is final decision for permissions
|
||||
# So return right away.
|
||||
if isinstance(entity_ids, bool):
|
||||
def allowed_entity_id_bool(entity_id: str, keys: Tuple[str]) -> bool:
|
||||
def allowed_entity_id_bool(entity_id: str, key: str) -> bool:
|
||||
"""Test if allowed entity_id."""
|
||||
return entity_ids # type: ignore
|
||||
|
||||
return allowed_entity_id_bool
|
||||
|
||||
if entity_ids is not None:
|
||||
def allowed_entity_id_dict(entity_id: str, keys: Tuple[str]) \
|
||||
def allowed_entity_id_dict(entity_id: str, key: str) \
|
||||
-> Union[None, bool]:
|
||||
"""Test if allowed entity_id."""
|
||||
return _entity_allowed(
|
||||
entity_ids.get(entity_id), keys) # type: ignore
|
||||
entity_ids.get(entity_id), key) # type: ignore
|
||||
|
||||
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, keys: Tuple[str]) \
|
||||
def allowed_domain_bool(entity_id: str, key: str) \
|
||||
-> Union[None, bool]:
|
||||
"""Test if allowed domain."""
|
||||
return domains
|
||||
@@ -98,31 +120,31 @@ def compile_entities(policy: CategoryType) \
|
||||
funcs.append(allowed_domain_bool)
|
||||
|
||||
elif domains is not None:
|
||||
def allowed_domain_dict(entity_id: str, keys: Tuple[str]) \
|
||||
def allowed_domain_dict(entity_id: str, key: str) \
|
||||
-> Union[None, bool]:
|
||||
"""Test if allowed domain."""
|
||||
domain = entity_id.split(".", 1)[0]
|
||||
return _entity_allowed(domains.get(domain), keys) # type: ignore
|
||||
return _entity_allowed(domains.get(domain), key) # type: ignore
|
||||
|
||||
funcs.append(allowed_domain_dict)
|
||||
|
||||
if isinstance(all_entities, bool):
|
||||
def allowed_all_entities_bool(entity_id: str, keys: Tuple[str]) \
|
||||
def allowed_all_entities_bool(entity_id: str, key: str) \
|
||||
-> Union[None, bool]:
|
||||
"""Test if allowed domain."""
|
||||
return all_entities
|
||||
funcs.append(allowed_all_entities_bool)
|
||||
|
||||
elif all_entities is not None:
|
||||
def allowed_all_entities_dict(entity_id: str, keys: Tuple[str]) \
|
||||
def allowed_all_entities_dict(entity_id: str, key: str) \
|
||||
-> Union[None, bool]:
|
||||
"""Test if allowed domain."""
|
||||
return _entity_allowed(all_entities, keys)
|
||||
return _entity_allowed(all_entities, key)
|
||||
funcs.append(allowed_all_entities_dict)
|
||||
|
||||
# Can happen if no valid subcategories specified
|
||||
if not funcs:
|
||||
def apply_policy_deny_all_2(entity_id: str, keys: Tuple[str]) -> bool:
|
||||
def apply_policy_deny_all_2(entity_id: str, key: str) -> bool:
|
||||
"""Decline all."""
|
||||
return False
|
||||
|
||||
@@ -132,16 +154,16 @@ def compile_entities(policy: CategoryType) \
|
||||
func = funcs[0]
|
||||
|
||||
@wraps(func)
|
||||
def apply_policy_func(entity_id: str, keys: Tuple[str]) -> bool:
|
||||
def apply_policy_func(entity_id: str, key: str) -> bool:
|
||||
"""Apply a single policy function."""
|
||||
return func(entity_id, keys) is True
|
||||
return func(entity_id, key) is True
|
||||
|
||||
return apply_policy_func
|
||||
|
||||
def apply_policy_funcs(entity_id: str, keys: Tuple[str]) -> bool:
|
||||
def apply_policy_funcs(entity_id: str, key: str) -> bool:
|
||||
"""Apply several policy functions."""
|
||||
for func in funcs:
|
||||
result = func(entity_id, keys)
|
||||
result = func(entity_id, key)
|
||||
if result is not None:
|
||||
return result
|
||||
return False
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from typing import ( # noqa: F401
|
||||
cast, Dict, List, Set)
|
||||
|
||||
from .common import PolicyType, CategoryType
|
||||
from .types import PolicyType, CategoryType
|
||||
|
||||
|
||||
def merge_policies(policies: List[PolicyType]) -> PolicyType:
|
||||
|
||||
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')
|
||||
14
homeassistant/auth/permissions/system_policies.py
Normal file
14
homeassistant/auth/permissions/system_policies.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""System policies."""
|
||||
from .const import CAT_ENTITIES, SUBCAT_ALL, POLICY_READ
|
||||
|
||||
ADMIN_POLICY = {
|
||||
CAT_ENTITIES: True,
|
||||
}
|
||||
|
||||
READ_ONLY_POLICY = {
|
||||
CAT_ENTITIES: {
|
||||
SUBCAT_ALL: {
|
||||
POLICY_READ: True
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -29,5 +28,3 @@ CategoryType = Union[
|
||||
|
||||
# Example: { entities: … }
|
||||
PolicyType = Mapping[str, CategoryType]
|
||||
|
||||
SUBCAT_ALL = 'all'
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ from homeassistant.const import (
|
||||
CONF_PENDING_TIME, CONF_TRIGGER_TIME)
|
||||
|
||||
|
||||
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 Demo alarm control panel platform."""
|
||||
add_entities([
|
||||
async_add_entities([
|
||||
manual.ManualAlarm(hass, 'Alarm', '1234', None, False, {
|
||||
STATE_ALARM_ARMED_AWAY: {
|
||||
CONF_DELAY_TIME: datetime.timedelta(seconds=0),
|
||||
|
||||
@@ -12,10 +12,10 @@ 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_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyialarm==0.2']
|
||||
REQUIREMENTS = ['pyialarm==0.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -89,6 +89,8 @@ class IAlarmPanel(alarm.AlarmControlPanel):
|
||||
state = STATE_ALARM_ARMED_AWAY
|
||||
elif status == self._client.ARMED_STAY:
|
||||
state = STATE_ALARM_ARMED_HOME
|
||||
elif status == self._client.TRIGGERED:
|
||||
state = STATE_ALARM_TRIGGERED
|
||||
else:
|
||||
state = None
|
||||
|
||||
|
||||
70
homeassistant/components/alarm_control_panel/lupusec.py
Normal file
70
homeassistant/components/alarm_control_panel/lupusec.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
This component provides HA alarm_control_panel support for Lupusec System.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.lupusec/
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanel
|
||||
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_TRIGGERED)
|
||||
|
||||
DEPENDENCIES = ['lupusec']
|
||||
|
||||
ICON = 'mdi:security'
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=2)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up an alarm control panel for a Lupusec device."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
data = hass.data[LUPUSEC_DOMAIN]
|
||||
|
||||
alarm_devices = [LupusecAlarm(data, data.lupusec.get_alarm())]
|
||||
|
||||
add_entities(alarm_devices)
|
||||
|
||||
|
||||
class LupusecAlarm(LupusecDevice, AlarmControlPanel):
|
||||
"""An alarm_control_panel implementation for Lupusec."""
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon."""
|
||||
return ICON
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
if self._device.is_standby:
|
||||
state = STATE_ALARM_DISARMED
|
||||
elif self._device.is_away:
|
||||
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
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
self._device.set_away()
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
self._device.set_standby()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
self._device.set_home()
|
||||
@@ -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,7 @@ 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)
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
self._state = state.state
|
||||
self._state_ts = state.last_updated
|
||||
|
||||
@@ -335,11 +335,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
return state_attr
|
||||
|
||||
def async_added_to_hass(self):
|
||||
"""Subscribe to MQTT events.
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to MQTT events."""
|
||||
async_track_state_change(
|
||||
self.hass, self.entity_id, self._async_state_changed_listener
|
||||
)
|
||||
@@ -359,7 +356,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
_LOGGER.warning("Received unexpected payload: %s", payload)
|
||||
return
|
||||
|
||||
return mqtt.async_subscribe(
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._command_topic, message_received, self._qos)
|
||||
|
||||
async def _async_state_changed_listener(self, entity_id, old_state,
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.const import (
|
||||
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)
|
||||
CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate, 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
|
||||
@@ -51,7 +51,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
|
||||
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 +59,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 +67,47 @@ 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,
|
||||
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._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)
|
||||
|
||||
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)
|
||||
|
||||
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 +119,16 @@ 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."""
|
||||
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 +138,7 @@ 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 state(self):
|
||||
@@ -147,9 +148,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 +163,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 +176,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 +189,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
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||
|
||||
|
||||
REQUIREMENTS = ['total_connect_client==0.20']
|
||||
REQUIREMENTS = ['total_connect_client==0.22']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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.5']
|
||||
|
||||
CONF_AREA_ID = 'area_id'
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ from homeassistant.components import (
|
||||
input_boolean, light, lock, media_player, scene, script, sensor, switch)
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, SERVICE_LOCK,
|
||||
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE,
|
||||
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)
|
||||
@@ -474,6 +474,26 @@ class _AlexaColorController(_AlexaInterface):
|
||||
def name(self):
|
||||
return 'Alexa.ColorController'
|
||||
|
||||
def properties_supported(self):
|
||||
return [{'name': 'color'}]
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
def get_property(self, name):
|
||||
if name != 'color':
|
||||
raise _UnsupportedProperty(name)
|
||||
|
||||
hue, saturation = self.entity.attributes.get(
|
||||
light.ATTR_HS_COLOR, (0, 0))
|
||||
|
||||
return {
|
||||
'hue': hue,
|
||||
'saturation': saturation / 100.0,
|
||||
'brightness': self.entity.attributes.get(
|
||||
light.ATTR_BRIGHTNESS, 0) / 255.0,
|
||||
}
|
||||
|
||||
|
||||
class _AlexaColorTemperatureController(_AlexaInterface):
|
||||
"""Implements Alexa.ColorTemperatureController.
|
||||
@@ -484,6 +504,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.
|
||||
@@ -717,6 +751,9 @@ class _ClimateCapabilities(_AlexaEntity):
|
||||
return [_DisplayCategory.THERMOSTAT]
|
||||
|
||||
def interfaces(self):
|
||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
if supported & climate.SUPPORT_ON_OFF:
|
||||
yield _AlexaPowerController(self.entity)
|
||||
yield _AlexaThermostatController(self.hass, self.entity)
|
||||
yield _AlexaTemperatureSensor(self.hass, self.entity)
|
||||
|
||||
@@ -1194,6 +1231,11 @@ async def async_api_discovery(hass, config, directive, context):
|
||||
discovery_endpoints = []
|
||||
|
||||
for entity in hass.states.async_all():
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
_LOGGER.debug("Not exposing %s because it is never exposed",
|
||||
entity.entity_id)
|
||||
continue
|
||||
|
||||
if not config.should_expose(entity.entity_id):
|
||||
_LOGGER.debug("Not exposing %s because filtered by config",
|
||||
entity.entity_id)
|
||||
@@ -1205,7 +1247,7 @@ async def async_api_discovery(hass, config, directive, context):
|
||||
|
||||
endpoint = {
|
||||
'displayCategories': alexa_entity.display_categories(),
|
||||
'additionalApplianceDetails': {},
|
||||
'cookie': {},
|
||||
'endpointId': alexa_entity.entity_id(),
|
||||
'friendlyName': alexa_entity.friendly_name(),
|
||||
'description': alexa_entity.description(),
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +22,9 @@ from homeassistant.const import (
|
||||
URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM,
|
||||
URL_API_TEMPLATE, __version__)
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.auth.permissions.const import POLICY_READ
|
||||
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
|
||||
@@ -81,6 +85,8 @@ class APIEventStream(HomeAssistantView):
|
||||
|
||||
async def get(self, request):
|
||||
"""Provide a streaming interface for the event bus."""
|
||||
if not request['hass_user'].is_admin:
|
||||
raise Unauthorized()
|
||||
hass = request.app['hass']
|
||||
stop_obj = object()
|
||||
to_write = asyncio.Queue(loop=hass.loop)
|
||||
@@ -185,7 +191,13 @@ class APIStatesView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get current states."""
|
||||
return self.json(request.app['hass'].states.async_all())
|
||||
user = request['hass_user']
|
||||
entity_perm = user.permissions.check_entity
|
||||
states = [
|
||||
state for state in request.app['hass'].states.async_all()
|
||||
if entity_perm(state.entity_id, 'read')
|
||||
]
|
||||
return self.json(states)
|
||||
|
||||
|
||||
class APIEntityStateView(HomeAssistantView):
|
||||
@@ -197,6 +209,10 @@ class APIEntityStateView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request, entity_id):
|
||||
"""Retrieve state of entity."""
|
||||
user = request['hass_user']
|
||||
if not user.permissions.check_entity(entity_id, POLICY_READ):
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
|
||||
state = request.app['hass'].states.get(entity_id)
|
||||
if state:
|
||||
return self.json(state)
|
||||
@@ -204,6 +220,8 @@ class APIEntityStateView(HomeAssistantView):
|
||||
|
||||
async def post(self, request, entity_id):
|
||||
"""Update state of entity."""
|
||||
if not request['hass_user'].is_admin:
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
hass = request.app['hass']
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -236,6 +254,8 @@ class APIEntityStateView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def delete(self, request, entity_id):
|
||||
"""Remove entity."""
|
||||
if not request['hass_user'].is_admin:
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
if request.app['hass'].states.async_remove(entity_id):
|
||||
return self.json_message("Entity removed.")
|
||||
return self.json_message("Entity not found.", HTTP_NOT_FOUND)
|
||||
@@ -261,6 +281,8 @@ class APIEventView(HomeAssistantView):
|
||||
|
||||
async def post(self, request, event_type):
|
||||
"""Fire events."""
|
||||
if not request['hass_user'].is_admin:
|
||||
raise Unauthorized()
|
||||
body = await request.text()
|
||||
try:
|
||||
event_data = json.loads(body) if body else None
|
||||
@@ -320,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)
|
||||
|
||||
@@ -346,6 +371,8 @@ class APITemplateView(HomeAssistantView):
|
||||
|
||||
async def post(self, request):
|
||||
"""Render a template."""
|
||||
if not request['hass_user'].is_admin:
|
||||
raise Unauthorized()
|
||||
try:
|
||||
data = await request.json()
|
||||
tpl = template.Template(data['template'], request.app['hass'])
|
||||
@@ -363,6 +390,8 @@ class APIErrorLog(HomeAssistantView):
|
||||
|
||||
async def get(self, request):
|
||||
"""Retrieve API error log."""
|
||||
if not request['hass_user'].is_admin:
|
||||
raise Unauthorized()
|
||||
return web.FileResponse(request.app['hass'].data[DATA_LOGGING])
|
||||
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
68
homeassistant/components/asuswrt.py
Normal file
68
homeassistant/components/asuswrt.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Support for ASUSWRT devices.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/asuswrt/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE,
|
||||
CONF_PROTOCOL)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
|
||||
REQUIREMENTS = ['aioasuswrt==1.1.13']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "asuswrt"
|
||||
DATA_ASUSWRT = DOMAIN
|
||||
|
||||
CONF_PUB_KEY = 'pub_key'
|
||||
CONF_SSH_KEY = 'ssh_key'
|
||||
CONF_REQUIRE_IP = 'require_ip'
|
||||
DEFAULT_SSH_PORT = 22
|
||||
SECRET_GROUP = 'Password or SSH Key'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']),
|
||||
vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']),
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port,
|
||||
vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean,
|
||||
vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string,
|
||||
vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile,
|
||||
vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the asuswrt component."""
|
||||
from aioasuswrt.asuswrt import AsusWrt
|
||||
conf = config[DOMAIN]
|
||||
|
||||
api = AsusWrt(conf[CONF_HOST], conf.get(CONF_PORT),
|
||||
conf.get(CONF_PROTOCOL) == 'telnet',
|
||||
conf[CONF_USERNAME],
|
||||
conf.get(CONF_PASSWORD, ''),
|
||||
conf.get('ssh_key', conf.get('pub_key', '')),
|
||||
conf.get(CONF_MODE), conf.get(CONF_REQUIRE_IP))
|
||||
|
||||
await api.connection.async_connect()
|
||||
if not api.is_connected:
|
||||
_LOGGER.error("Unable to setup asuswrt component")
|
||||
return False
|
||||
|
||||
hass.data[DATA_ASUSWRT] = api
|
||||
|
||||
hass.async_create_task(async_load_platform(
|
||||
hass, 'sensor', DOMAIN, {}, config))
|
||||
hass.async_create_task(async_load_platform(
|
||||
hass, 'device_tracker', DOMAIN, {}, config))
|
||||
return True
|
||||
@@ -12,7 +12,7 @@ from requests import RequestException
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import (
|
||||
CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT)
|
||||
CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CONFIGURING = {}
|
||||
|
||||
REQUIREMENTS = ['py-august==0.6.0']
|
||||
REQUIREMENTS = ['py-august==0.7.0']
|
||||
|
||||
DEFAULT_TIMEOUT = 10
|
||||
ACTIVITY_FETCH_LIMIT = 10
|
||||
@@ -116,7 +116,8 @@ def setup_august(hass, config, api, authenticator):
|
||||
if DOMAIN in _CONFIGURING:
|
||||
hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN))
|
||||
|
||||
hass.data[DATA_AUGUST] = AugustData(api, authentication.access_token)
|
||||
hass.data[DATA_AUGUST] = AugustData(
|
||||
hass, api, authentication.access_token)
|
||||
|
||||
for component in AUGUST_COMPONENTS:
|
||||
discovery.load_platform(hass, component, DOMAIN, {}, config)
|
||||
@@ -136,9 +137,16 @@ def setup(hass, config):
|
||||
"""Set up the August component."""
|
||||
from august.api import Api
|
||||
from august.authenticator import Authenticator
|
||||
from requests import Session
|
||||
|
||||
conf = config[DOMAIN]
|
||||
api = Api(timeout=conf.get(CONF_TIMEOUT))
|
||||
api_http_session = None
|
||||
try:
|
||||
api_http_session = Session()
|
||||
except RequestException as ex:
|
||||
_LOGGER.warning("Creating HTTP session failed with: %s", str(ex))
|
||||
|
||||
api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session)
|
||||
|
||||
authenticator = Authenticator(
|
||||
api,
|
||||
@@ -148,14 +156,29 @@ 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)
|
||||
|
||||
|
||||
class AugustData:
|
||||
"""August data object."""
|
||||
|
||||
def __init__(self, api, access_token):
|
||||
def __init__(self, hass, api, access_token):
|
||||
"""Init August data object."""
|
||||
self._hass = hass
|
||||
self._api = api
|
||||
self._access_token = access_token
|
||||
self._doorbells = self._api.get_doorbells(self._access_token) or []
|
||||
@@ -201,8 +224,11 @@ class AugustData:
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT):
|
||||
"""Update data object with latest from August API."""
|
||||
_LOGGER.debug("Updating device activities")
|
||||
_LOGGER.debug("Start retrieving device activities")
|
||||
for house_id in self.house_ids:
|
||||
_LOGGER.debug("Updating device activity for house id %s",
|
||||
house_id)
|
||||
|
||||
activities = self._api.get_house_activities(self._access_token,
|
||||
house_id,
|
||||
limit=limit)
|
||||
@@ -211,6 +237,7 @@ class AugustData:
|
||||
for device_id in device_ids:
|
||||
self._activities_by_id[device_id] = [a for a in activities if
|
||||
a.device_id == device_id]
|
||||
_LOGGER.debug("Completed retrieving device activities")
|
||||
|
||||
def get_doorbell_detail(self, doorbell_id):
|
||||
"""Return doorbell detail."""
|
||||
@@ -223,7 +250,7 @@ class AugustData:
|
||||
|
||||
_LOGGER.debug("Start retrieving doorbell details")
|
||||
for doorbell in self._doorbells:
|
||||
_LOGGER.debug("Updating status for %s",
|
||||
_LOGGER.debug("Updating doorbell status for %s",
|
||||
doorbell.device_name)
|
||||
try:
|
||||
detail_by_id[doorbell.device_id] =\
|
||||
@@ -267,7 +294,7 @@ class AugustData:
|
||||
|
||||
_LOGGER.debug("Start retrieving door status")
|
||||
for lock in self._locks:
|
||||
_LOGGER.debug("Updating status for %s",
|
||||
_LOGGER.debug("Updating door status for %s",
|
||||
lock.device_name)
|
||||
|
||||
try:
|
||||
@@ -291,7 +318,7 @@ class AugustData:
|
||||
|
||||
_LOGGER.debug("Start retrieving locks status")
|
||||
for lock in self._locks:
|
||||
_LOGGER.debug("Updating status for %s",
|
||||
_LOGGER.debug("Updating lock status for %s",
|
||||
lock.device_name)
|
||||
try:
|
||||
status_by_id[lock.device_id] = self._api.get_lock_status(
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"title": "Configureu 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:",
|
||||
"description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdu\u00efu-la a continuaci\u00f3:",
|
||||
"title": "Verifiqueu la configuraci\u00f3"
|
||||
}
|
||||
},
|
||||
|
||||
34
homeassistant/components/auth/.translations/cs.json
Normal file
34
homeassistant/components/auth/.translations/cs.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "\u017d\u00e1dn\u00e9 oznamovac\u00ed slu\u017eby nejsou k dispozici."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Vyberte pros\u00edm jednu z oznamovac\u00edch slu\u017eeb:",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/es.json
Normal file
35
homeassistant/components/auth/.translations/es.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "No hay servicios de notificaci\u00f3n disponibles."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntelo de nuevo."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Seleccione uno de los servicios de notificaci\u00f3n:",
|
||||
"title": "Configure una contrase\u00f1a de un solo uso entregada por el componente de notificaci\u00f3n"
|
||||
},
|
||||
"setup": {
|
||||
"description": "Se ha enviado una contrase\u00f1a de un solo uso a trav\u00e9s de ** notificar. {notify_service} **. Por favor introd\u00facela a continuaci\u00f3n:",
|
||||
"title": "Verificar la configuraci\u00f3n"
|
||||
}
|
||||
},
|
||||
"title": "Notificar la contrase\u00f1a de un solo uso"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntalo de nuevo. Si recibes este error de forma consistente, por favor aseg\u00farate de que el reloj de tu Home Assistant es correcto."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanea el c\u00f3digo QR con tu aplicaci\u00f3n de autenticaci\u00f3n. Si no tienes una, te recomendamos [Autenticador de Google] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \nDespu\u00e9s de escanear el c\u00f3digo, introduce el c\u00f3digo de seis d\u00edgitos de tu aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tienes problemas para escanear el c\u00f3digo QR, realiza una configuraci\u00f3n manual con el c\u00f3digo ** ` {code} ` **.",
|
||||
"title": "Configure la autenticaci\u00f3n de dos factores utilizando TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,27 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "Nessun servizio di notifica disponibile."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Codice non valido, per favore riprovare."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Selezionare uno dei servizi di notifica:"
|
||||
},
|
||||
"setup": {
|
||||
"description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Per favore, inseriscila qui sotto:",
|
||||
"title": "Verifica l'installazione"
|
||||
}
|
||||
},
|
||||
"title": "Notifica la Password monouso"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Codice non valido, per favore riprovare. Se riscontri spesso questo errore, assicurati che l'orologio del sistema Home Assistant sia accurato."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Per attivare l'autenticazione a due fattori utilizzando password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Dopo aver scansionato il codice, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con codice ** ` {code} ` **.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
@@ -368,8 +375,6 @@ def _async_get_action(hass, config, name):
|
||||
async def action(entity_id, variables, context):
|
||||
"""Execute an action."""
|
||||
_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)
|
||||
|
||||
return action
|
||||
@@ -400,6 +405,9 @@ async def _async_process_trigger(hass, config, trigger_configs, name, action):
|
||||
This method is a coroutine.
|
||||
"""
|
||||
removes = []
|
||||
info = {
|
||||
'name': name
|
||||
}
|
||||
|
||||
for conf in trigger_configs:
|
||||
platform = await async_prepare_setup_platform(
|
||||
@@ -408,7 +416,7 @@ async def _async_process_trigger(hass, config, trigger_configs, name, action):
|
||||
if platform is None:
|
||||
return None
|
||||
|
||||
remove = await platform.async_trigger(hass, conf, action)
|
||||
remove = await platform.async_trigger(hass, conf, action, info)
|
||||
|
||||
if not remove:
|
||||
_LOGGER.error("Error setting up trigger %s", name)
|
||||
|
||||
@@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for events based on configuration."""
|
||||
event_type = config.get(CONF_EVENT_TYPE)
|
||||
event_data_schema = vol.Schema(
|
||||
|
||||
@@ -33,7 +33,7 @@ def source_match(state, source):
|
||||
return state and state.attributes.get('source') == source
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for state changes based on configuration."""
|
||||
source = config.get(CONF_SOURCE).lower()
|
||||
zone_entity_id = config.get(CONF_ZONE)
|
||||
|
||||
@@ -22,7 +22,7 @@ TRIGGER_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for events based on configuration."""
|
||||
event = config.get(CONF_EVENT)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ TRIGGER_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for events based on configuration."""
|
||||
number = config.get(CONF_NUMBER)
|
||||
held_more_than = config.get(CONF_HELD_MORE_THAN)
|
||||
|
||||
@@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for state changes based on configuration."""
|
||||
topic = config.get(CONF_TOPIC)
|
||||
payload = config.get(CONF_PAYLOAD)
|
||||
|
||||
@@ -28,7 +28,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for state changes based on configuration."""
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
below = config.get(CONF_BELOW)
|
||||
|
||||
@@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({
|
||||
}), cv.key_dependency(CONF_FOR, CONF_TO))
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for state changes based on configuration."""
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
from_state = config.get(CONF_FROM, MATCH_ALL)
|
||||
|
||||
@@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for events based on configuration."""
|
||||
event = config.get(CONF_EVENT)
|
||||
offset = config.get(CONF_OFFSET)
|
||||
|
||||
@@ -22,7 +22,7 @@ TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for state changes based on configuration."""
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
value_template.hass = hass
|
||||
|
||||
@@ -28,7 +28,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({
|
||||
}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AT))
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for state changes based on configuration."""
|
||||
if CONF_AT in config:
|
||||
at_time = config.get(CONF_AT)
|
||||
|
||||
@@ -14,6 +14,8 @@ from homeassistant.core import callback
|
||||
from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from . import DOMAIN as AUTOMATION_DOMAIN
|
||||
|
||||
DEPENDENCIES = ('webhook',)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -39,10 +41,11 @@ async def _handle_webhook(action, hass, webhook_id, request):
|
||||
hass.async_run_job(action, {'trigger': result})
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Trigger based on incoming webhooks."""
|
||||
webhook_id = config.get(CONF_WEBHOOK_ID)
|
||||
hass.components.webhook.async_register(
|
||||
AUTOMATION_DOMAIN, automation_info['name'],
|
||||
webhook_id, partial(_handle_webhook, action))
|
||||
|
||||
@callback
|
||||
|
||||
@@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action):
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for state changes based on configuration."""
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
zone_entity_id = config.get(CONF_ZONE)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ https://home-assistant.io/components/binary_sensor.deconz/
|
||||
"""
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.deconz.const import (
|
||||
ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ,
|
||||
DECONZ_DOMAIN)
|
||||
ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DECONZ_REACHABLE,
|
||||
DOMAIN as DECONZ_DOMAIN)
|
||||
from homeassistant.const import ATTR_BATTERY_LEVEL
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE
|
||||
@@ -24,6 +24,8 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the deCONZ binary sensor."""
|
||||
gateway = hass.data[DECONZ_DOMAIN]
|
||||
|
||||
@callback
|
||||
def async_add_sensor(sensors):
|
||||
"""Add binary sensor from deCONZ."""
|
||||
@@ -33,30 +35,35 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
for sensor in sensors:
|
||||
if sensor.type in DECONZ_BINARY_SENSOR and \
|
||||
not (not allow_clip_sensor and sensor.type.startswith('CLIP')):
|
||||
entities.append(DeconzBinarySensor(sensor))
|
||||
entities.append(DeconzBinarySensor(sensor, gateway))
|
||||
async_add_entities(entities, True)
|
||||
|
||||
hass.data[DATA_DECONZ].listeners.append(
|
||||
gateway.listeners.append(
|
||||
async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor))
|
||||
|
||||
async_add_sensor(hass.data[DATA_DECONZ].api.sensors.values())
|
||||
async_add_sensor(gateway.api.sensors.values())
|
||||
|
||||
|
||||
class DeconzBinarySensor(BinarySensorDevice):
|
||||
"""Representation of a binary sensor."""
|
||||
|
||||
def __init__(self, sensor):
|
||||
def __init__(self, sensor, gateway):
|
||||
"""Set up sensor and add update callback to get data from websocket."""
|
||||
self._sensor = sensor
|
||||
self.gateway = gateway
|
||||
self.unsub_dispatcher = None
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe sensors events."""
|
||||
self._sensor.register_async_callback(self.async_update_callback)
|
||||
self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \
|
||||
self._sensor.deconz_id
|
||||
self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id
|
||||
self.unsub_dispatcher = async_dispatcher_connect(
|
||||
self.hass, DECONZ_REACHABLE, self.async_update_callback)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect sensor object when removed."""
|
||||
if self.unsub_dispatcher is not None:
|
||||
self.unsub_dispatcher()
|
||||
self._sensor.remove_callback(self.async_update_callback)
|
||||
self._sensor = None
|
||||
|
||||
@@ -101,7 +108,7 @@ class DeconzBinarySensor(BinarySensorDevice):
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if sensor is available."""
|
||||
return self._sensor.reachable
|
||||
return self.gateway.available and self._sensor.reachable
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -128,7 +135,7 @@ class DeconzBinarySensor(BinarySensorDevice):
|
||||
self._sensor.uniqueid.count(':') != 7):
|
||||
return None
|
||||
serial = self._sensor.uniqueid.split('-', 1)[0]
|
||||
bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid
|
||||
bridgeid = self.gateway.api.config.bridgeid
|
||||
return {
|
||||
'connections': {(CONNECTION_ZIGBEE, serial)},
|
||||
'identifiers': {(DECONZ_DOMAIN, serial)},
|
||||
|
||||
76
homeassistant/components/binary_sensor/fibaro.py
Normal file
76
homeassistant/components/binary_sensor/fibaro.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Support for Fibaro binary sensors.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.fibaro/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, ENTITY_ID_FORMAT)
|
||||
from homeassistant.components.fibaro import (
|
||||
FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice)
|
||||
|
||||
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'],
|
||||
'com.fibaro.FGMS001': ['Motion', 'mdi:run', 'motion'],
|
||||
'com.fibaro.heatDetector': ['Heat', 'mdi:fire', 'heat'],
|
||||
}
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Perform the setup for Fibaro controller devices."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
add_entities(
|
||||
[FibaroBinarySensor(device, hass.data[FIBARO_CONTROLLER])
|
||||
for device in hass.data[FIBARO_DEVICES]['binary_sensor']], True)
|
||||
|
||||
|
||||
class FibaroBinarySensor(FibaroDevice, BinarySensorDevice):
|
||||
"""Representation of a Fibaro Binary Sensor."""
|
||||
|
||||
def __init__(self, fibaro_device, controller):
|
||||
"""Initialize the binary_sensor."""
|
||||
self._state = None
|
||||
super().__init__(fibaro_device, controller)
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
|
||||
stype = None
|
||||
if fibaro_device.type in SENSOR_TYPES:
|
||||
stype = fibaro_device.type
|
||||
elif fibaro_device.baseType in SENSOR_TYPES:
|
||||
stype = fibaro_device.baseType
|
||||
if stype:
|
||||
self._device_class = SENSOR_TYPES[stype][2]
|
||||
self._icon = SENSOR_TYPES[stype][1]
|
||||
else:
|
||||
self._device_class = None
|
||||
self._icon = None
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Icon to use in the frontend, if any."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class of the sensor."""
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._state
|
||||
|
||||
def update(self):
|
||||
"""Get the latest data and update the state."""
|
||||
self._state = self.current_binary_state
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -57,7 +57,8 @@ class InsteonBinarySensor(InsteonEntity, BinarySensorDevice):
|
||||
"""Return the boolean response if the node is on."""
|
||||
on_val = bool(self._insteon_device_state.value)
|
||||
|
||||
if self._insteon_device_state.name == 'lightSensor':
|
||||
if self._insteon_device_state.name in ['lightSensor',
|
||||
'openClosedSensor']:
|
||||
return not on_val
|
||||
|
||||
return on_val
|
||||
|
||||
53
homeassistant/components/binary_sensor/lupusec.py
Normal file
53
homeassistant/components/binary_sensor/lupusec.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
This component provides HA binary_sensor support for Lupusec Security System.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.lupusec/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.lupusec import (LupusecDevice,
|
||||
DOMAIN as LUPUSEC_DOMAIN)
|
||||
from homeassistant.components.binary_sensor import (BinarySensorDevice,
|
||||
DEVICE_CLASSES)
|
||||
|
||||
DEPENDENCIES = ['lupusec']
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=2)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up a sensor for an Lupusec device."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
import lupupy.constants as CONST
|
||||
|
||||
data = hass.data[LUPUSEC_DOMAIN]
|
||||
|
||||
device_types = [CONST.TYPE_OPENING]
|
||||
|
||||
devices = []
|
||||
for device in data.lupusec.get_devices(generic_type=device_types):
|
||||
devices.append(LupusecBinarySensor(data, device))
|
||||
|
||||
add_entities(devices)
|
||||
|
||||
|
||||
class LupusecBinarySensor(LupusecDevice, BinarySensorDevice):
|
||||
"""A binary sensor implementation for Lupusec device."""
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self._device.is_on
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of the binary sensor."""
|
||||
if self._device.generic_type not in DEVICE_CLASSES:
|
||||
return None
|
||||
return self._device.generic_type
|
||||
@@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.mqtt/
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -19,7 +18,8 @@ from homeassistant.const import (
|
||||
from homeassistant.components.mqtt import (
|
||||
ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC,
|
||||
CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS,
|
||||
MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo)
|
||||
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,8 +45,8 @@ 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)
|
||||
@@ -55,7 +55,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({
|
||||
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 +63,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,64 +71,53 @@ 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.get(CONF_NAME),
|
||||
config.get(CONF_STATE_TOPIC),
|
||||
config.get(CONF_AVAILABILITY_TOPIC),
|
||||
config.get(CONF_DEVICE_CLASS),
|
||||
config.get(CONF_QOS),
|
||||
config.get(CONF_FORCE_UPDATE),
|
||||
config.get(CONF_OFF_DELAY),
|
||||
config.get(CONF_PAYLOAD_ON),
|
||||
config.get(CONF_PAYLOAD_OFF),
|
||||
config.get(CONF_PAYLOAD_AVAILABLE),
|
||||
config.get(CONF_PAYLOAD_NOT_AVAILABLE),
|
||||
value_template,
|
||||
config.get(CONF_UNIQUE_ID),
|
||||
config.get(CONF_DEVICE),
|
||||
discovery_hash,
|
||||
)])
|
||||
async_add_entities([MqttBinarySensor(config, discovery_hash)])
|
||||
|
||||
|
||||
class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate,
|
||||
MqttEntityDeviceInfo, BinarySensorDevice):
|
||||
"""Representation a binary sensor that is updated by MQTT."""
|
||||
|
||||
def __init__(self, name, state_topic, availability_topic, device_class,
|
||||
qos, force_update, off_delay, payload_on, payload_off,
|
||||
payload_available, payload_not_available, value_template,
|
||||
unique_id: Optional[str], device_config: Optional[ConfigType],
|
||||
discovery_hash):
|
||||
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
|
||||
|
||||
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)
|
||||
MqttDiscoveryUpdate.__init__(self, discovery_hash,
|
||||
self.discovery_update)
|
||||
MqttEntityDeviceInfo.__init__(self, device_config)
|
||||
self._name = name
|
||||
self._state = None
|
||||
self._state_topic = state_topic
|
||||
self._device_class = device_class
|
||||
self._payload_on = payload_on
|
||||
self._payload_off = payload_off
|
||||
self._qos = qos
|
||||
self._force_update = force_update
|
||||
self._off_delay = off_delay
|
||||
self._template = value_template
|
||||
self._unique_id = unique_id
|
||||
self._discovery_hash = discovery_hash
|
||||
self._delay_listener = None
|
||||
|
||||
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."""
|
||||
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):
|
||||
@@ -140,30 +129,42 @@ 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(
|
||||
value_template = self._config.get(CONF_VALUE_TEMPLATE)
|
||||
if value_template is not None:
|
||||
payload = value_template.async_render_with_possible_json_value(
|
||||
payload)
|
||||
if payload == self._payload_on:
|
||||
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()
|
||||
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._state_topic, state_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': state_message_received,
|
||||
'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)
|
||||
await MqttAvailability.async_will_remove_from_hass(self)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -173,7 +174,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):
|
||||
@@ -183,12 +184,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):
|
||||
|
||||
111
homeassistant/components/binary_sensor/point.py
Normal file
111
homeassistant/components/binary_sensor/point.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Support for Minut Point.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.point/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as PARENT_DOMAIN, BinarySensorDevice)
|
||||
from homeassistant.components.point import MinutPointEntity
|
||||
from homeassistant.components.point.const import (
|
||||
DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EVENTS = {
|
||||
'battery': # On means low, Off means normal
|
||||
('battery_low', ''),
|
||||
'button_press': # On means the button was pressed, Off means normal
|
||||
('short_button_press', ''),
|
||||
'cold': # On means cold, Off means normal
|
||||
('temperature_low', 'temperature_risen_normal'),
|
||||
'connectivity': # On means connected, Off means disconnected
|
||||
('device_online', 'device_offline'),
|
||||
'dry': # On means too dry, Off means normal
|
||||
('humidity_low', 'humidity_risen_normal'),
|
||||
'heat': # On means hot, Off means normal
|
||||
('temperature_high', 'temperature_dropped_normal'),
|
||||
'moisture': # On means wet, Off means dry
|
||||
('humidity_high', 'humidity_dropped_normal'),
|
||||
'sound': # On means sound detected, Off means no sound (clear)
|
||||
('avg_sound_high', 'sound_level_dropped_normal'),
|
||||
'tamper': # On means the point was removed or attached
|
||||
('tamper', ''),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up a Point's binary sensors based on a config entry."""
|
||||
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(PARENT_DOMAIN, POINT_DOMAIN),
|
||||
async_discover_sensor)
|
||||
|
||||
|
||||
class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice):
|
||||
"""The platform class required by Home Assistant."""
|
||||
|
||||
def __init__(self, point_client, device_id, device_class):
|
||||
"""Initialize the entity."""
|
||||
super().__init__(point_client, device_id, device_class)
|
||||
|
||||
self._async_unsub_hook_dispatcher_connect = None
|
||||
self._events = EVENTS[device_class]
|
||||
self._is_on = None
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Call when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect(
|
||||
self.hass, SIGNAL_WEBHOOK, self._webhook_event)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Disconnect dispatcher listener when removed."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self._async_unsub_hook_dispatcher_connect:
|
||||
self._async_unsub_hook_dispatcher_connect()
|
||||
|
||||
@callback
|
||||
def _update_callback(self):
|
||||
"""Update the value of the sensor."""
|
||||
if not self.is_updated:
|
||||
return
|
||||
if self._events[0] in self.device.ongoing_events:
|
||||
self._is_on = True
|
||||
else:
|
||||
self._is_on = None
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@callback
|
||||
def _webhook_event(self, data, webhook):
|
||||
"""Process new event from the webhook."""
|
||||
if self.device.webhook != webhook:
|
||||
return
|
||||
_type = data.get('event', {}).get('type')
|
||||
if _type not in self._events:
|
||||
return
|
||||
_LOGGER.debug("Recieved webhook: %s", _type)
|
||||
if _type == self._events[0]:
|
||||
self._is_on = True
|
||||
if _type == self._events[1]:
|
||||
self._is_on = None
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the state of the binary sensor."""
|
||||
if self.device_class == 'connectivity':
|
||||
# connectivity is the other way around.
|
||||
return not self._is_on
|
||||
return self._is_on
|
||||
@@ -8,28 +8,29 @@ import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.rainmachine import (
|
||||
BINARY_SENSORS, DATA_RAINMACHINE, SENSOR_UPDATE_TOPIC, TYPE_FREEZE,
|
||||
TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, TYPE_HOURLY, TYPE_MONTH,
|
||||
TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, RainMachineEntity)
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
BINARY_SENSORS, DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN,
|
||||
SENSOR_UPDATE_TOPIC, TYPE_FREEZE, TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS,
|
||||
TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY,
|
||||
RainMachineEntity)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
DEPENDENCIES = ['rainmachine']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the RainMachine Switch platform."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Set up RainMachine binary sensors based on the old way."""
|
||||
pass
|
||||
|
||||
rainmachine = hass.data[DATA_RAINMACHINE]
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up RainMachine binary sensors based on a config entry."""
|
||||
rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id]
|
||||
|
||||
binary_sensors = []
|
||||
for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
|
||||
for sensor_type in rainmachine.binary_sensor_conditions:
|
||||
name, icon = BINARY_SENSORS[sensor_type]
|
||||
binary_sensors.append(
|
||||
RainMachineBinarySensor(rainmachine, sensor_type, name, icon))
|
||||
@@ -70,15 +71,15 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice):
|
||||
return '{0}_{1}'.format(
|
||||
self.rainmachine.device_mac.replace(':', ''), self._sensor_type)
|
||||
|
||||
@callback
|
||||
def _update_data(self):
|
||||
"""Update the state."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
async_dispatcher_connect(
|
||||
self.hass, SENSOR_UPDATE_TOPIC, self._update_data)
|
||||
@callback
|
||||
def update():
|
||||
"""Update the state."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
self._dispatcher_handlers.append(async_dispatcher_connect(
|
||||
self.hass, SENSOR_UPDATE_TOPIC, update))
|
||||
|
||||
async def async_update(self):
|
||||
"""Update the state."""
|
||||
|
||||
@@ -14,66 +14,69 @@ 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
|
||||
|
||||
data = hass.data[SENSE_DATA]
|
||||
|
||||
sense_devices = data.get_discovered_device_data()
|
||||
devices = [SenseDevice(data, device) for device in sense_devices]
|
||||
devices = [SenseDevice(data, device) for device in sense_devices
|
||||
if device['tags']['DeviceListAllowed'] == 'true']
|
||||
add_entities(devices)
|
||||
|
||||
|
||||
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,8 +9,9 @@ https://home-assistant.io/components/binary_sensor.tellduslive/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.tellduslive import TelldusLiveEntity
|
||||
from homeassistant.components import tellduslive
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.tellduslive.entry import TelldusLiveEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,8 +20,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up Tellstick sensors."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
client = hass.data[tellduslive.DOMAIN]
|
||||
add_entities(
|
||||
TelldusLiveSensor(hass, binary_sensor)
|
||||
TelldusLiveSensor(client, binary_sensor)
|
||||
for binary_sensor in discovery_info
|
||||
)
|
||||
|
||||
|
||||
@@ -58,10 +58,12 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
entity_ids = set()
|
||||
manual_entity_ids = device_config.get(ATTR_ENTITY_ID)
|
||||
|
||||
for template in (
|
||||
value_template,
|
||||
icon_template,
|
||||
entity_picture_template,
|
||||
invalid_templates = []
|
||||
|
||||
for tpl_name, template in (
|
||||
(CONF_VALUE_TEMPLATE, value_template),
|
||||
(CONF_ICON_TEMPLATE, icon_template),
|
||||
(CONF_ENTITY_PICTURE_TEMPLATE, entity_picture_template),
|
||||
):
|
||||
if template is None:
|
||||
continue
|
||||
@@ -73,6 +75,8 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
template_entity_ids = template.extract_entities()
|
||||
if template_entity_ids == MATCH_ALL:
|
||||
entity_ids = MATCH_ALL
|
||||
# Cut off _template from name
|
||||
invalid_templates.append(tpl_name[:-9])
|
||||
elif entity_ids != MATCH_ALL:
|
||||
entity_ids |= set(template_entity_ids)
|
||||
|
||||
@@ -81,6 +85,14 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
elif entity_ids != MATCH_ALL:
|
||||
entity_ids = list(entity_ids)
|
||||
|
||||
if invalid_templates:
|
||||
_LOGGER.warning(
|
||||
'Template binary sensor %s has no entity ids configured to'
|
||||
' track nor were we able to extract the entities to track'
|
||||
' from the %s template(s). This entity will only be able'
|
||||
' to be updated manually.',
|
||||
device, ', '.join(invalid_templates))
|
||||
|
||||
friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device)
|
||||
device_class = device_config.get(CONF_DEVICE_CLASS)
|
||||
delay_on = device_config.get(CONF_DELAY_ON)
|
||||
@@ -132,10 +144,12 @@ class BinarySensorTemplate(BinarySensorDevice):
|
||||
@callback
|
||||
def template_bsensor_startup(event):
|
||||
"""Update template on startup."""
|
||||
async_track_state_change(
|
||||
self.hass, self._entities, template_bsensor_state_listener)
|
||||
if self._entities != MATCH_ALL:
|
||||
# Track state change only for valid templates
|
||||
async_track_state_change(
|
||||
self.hass, self._entities, template_bsensor_state_listener)
|
||||
|
||||
self.hass.async_add_job(self.async_check_state)
|
||||
self.async_check_state()
|
||||
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_START, template_bsensor_startup)
|
||||
@@ -233,3 +247,7 @@ class BinarySensorTemplate(BinarySensorDevice):
|
||||
async_track_same_state(
|
||||
self.hass, period, set_state, entity_ids=self._entities,
|
||||
async_check_same_func=lambda *args: self._async_render() == state)
|
||||
|
||||
async def async_update(self):
|
||||
"""Force update of the state from the template."""
|
||||
self.async_check_state()
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.util import utcnow
|
||||
|
||||
REQUIREMENTS = ['numpy==1.15.3']
|
||||
REQUIREMENTS = ['numpy==1.15.4']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
132
homeassistant/components/binary_sensor/w800rf32.py
Normal file
132
homeassistant/components/binary_sensor/w800rf32.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Support for w800rf32 binary sensors.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.w800rf32/
|
||||
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
|
||||
from homeassistant.components.w800rf32 import (W800RF32_DEVICE)
|
||||
from homeassistant.const import (CONF_DEVICE_CLASS, CONF_NAME, CONF_DEVICES)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import event as evt
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.helpers.dispatcher import (async_dispatcher_connect)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['w800rf32']
|
||||
CONF_OFF_DELAY = 'off_delay'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_DEVICES): {
|
||||
cv.string: vol.Schema({
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_OFF_DELAY):
|
||||
vol.All(cv.time_period, cv.positive_timedelta)
|
||||
})
|
||||
},
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config,
|
||||
add_entities, discovery_info=None):
|
||||
"""Set up the Binary Sensor platform to w800rf32."""
|
||||
binary_sensors = []
|
||||
# device_id --> "c1 or a3" X10 device. entity (type dictionary)
|
||||
# --> name, device_class etc
|
||||
for device_id, entity in config[CONF_DEVICES].items():
|
||||
|
||||
_LOGGER.debug("Add %s w800rf32.binary_sensor (class %s)",
|
||||
entity[CONF_NAME], entity.get(CONF_DEVICE_CLASS))
|
||||
|
||||
device = W800rf32BinarySensor(
|
||||
device_id, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS),
|
||||
entity.get(CONF_OFF_DELAY))
|
||||
|
||||
binary_sensors.append(device)
|
||||
|
||||
add_entities(binary_sensors)
|
||||
|
||||
|
||||
class W800rf32BinarySensor(BinarySensorDevice):
|
||||
"""A representation of a w800rf32 binary sensor."""
|
||||
|
||||
def __init__(self, device_id, name, device_class=None, off_delay=None):
|
||||
"""Initialize the w800rf32 sensor."""
|
||||
self._signal = W800RF32_DEVICE.format(device_id)
|
||||
self._name = name
|
||||
self._device_class = device_class
|
||||
self._off_delay = off_delay
|
||||
self._state = False
|
||||
self._delay_listener = None
|
||||
|
||||
@callback
|
||||
def _off_delay_listener(self, now):
|
||||
"""Switch device off after a delay."""
|
||||
self._delay_listener = None
|
||||
self.update_state(False)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the device name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the sensor class."""
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the sensor state is True."""
|
||||
return self._state
|
||||
|
||||
@callback
|
||||
def binary_sensor_update(self, event):
|
||||
"""Call for control updates from the w800rf32 gateway."""
|
||||
import W800rf32 as w800rf32mod
|
||||
|
||||
if not isinstance(event, w800rf32mod.W800rf32Event):
|
||||
return
|
||||
|
||||
dev_id = event.device
|
||||
command = event.command
|
||||
|
||||
_LOGGER.debug(
|
||||
"BinarySensor update (Device ID: %s Command %s ...)",
|
||||
dev_id, command)
|
||||
|
||||
# Update the w800rf32 device state
|
||||
if command in ('On', 'Off'):
|
||||
is_on = command == 'On'
|
||||
self.update_state(is_on)
|
||||
|
||||
if (self.is_on and self._off_delay is not None and
|
||||
self._delay_listener is None):
|
||||
|
||||
self._delay_listener = evt.async_track_point_in_time(
|
||||
self.hass, self._off_delay_listener,
|
||||
dt_util.utcnow() + self._off_delay)
|
||||
|
||||
def update_state(self, state):
|
||||
"""Update the state of the device."""
|
||||
self._state = state
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register update callback."""
|
||||
async_dispatcher_connect(self.hass, self._signal,
|
||||
self.binary_sensor_update)
|
||||
@@ -209,7 +209,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor):
|
||||
else:
|
||||
self._should_poll = True
|
||||
if self.entity_id is not None:
|
||||
self._hass.bus.fire('motion', {
|
||||
self._hass.bus.fire('xiaomi_aqara.motion', {
|
||||
'entity_id': self.entity_id
|
||||
})
|
||||
|
||||
@@ -357,6 +357,9 @@ class XiaomiVibration(XiaomiBinarySensor):
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway."""
|
||||
value = data.get(self._data_key)
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if value not in ('vibrate', 'tilt', 'free_fall'):
|
||||
_LOGGER.warning("Unsupported movement_type detected: %s",
|
||||
value)
|
||||
@@ -414,7 +417,7 @@ class XiaomiButton(XiaomiBinarySensor):
|
||||
_LOGGER.warning("Unsupported click_type detected: %s", value)
|
||||
return False
|
||||
|
||||
self._hass.bus.fire('click', {
|
||||
self._hass.bus.fire('xiaomi_aqara.click', {
|
||||
'entity_id': self.entity_id,
|
||||
'click_type': click_type
|
||||
})
|
||||
@@ -450,14 +453,14 @@ class XiaomiCube(XiaomiBinarySensor):
|
||||
def parse_data(self, data, raw_data):
|
||||
"""Parse data sent by gateway."""
|
||||
if self._data_key in data:
|
||||
self._hass.bus.fire('cube_action', {
|
||||
self._hass.bus.fire('xiaomi_aqara.cube_action', {
|
||||
'entity_id': self.entity_id,
|
||||
'action_type': data[self._data_key]
|
||||
})
|
||||
self._last_action = data[self._data_key]
|
||||
|
||||
if 'rotate' in data:
|
||||
self._hass.bus.fire('cube_action', {
|
||||
self._hass.bus.fire('xiaomi_aqara.cube_action', {
|
||||
'entity_id': self.entity_id,
|
||||
'action_type': 'rotate',
|
||||
'action_value': float(data['rotate'].replace(",", "."))
|
||||
|
||||
@@ -7,7 +7,11 @@ 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, ZHA_DISCOVERY_NEW)
|
||||
from homeassistant.components.zha.entities import ZhaEntity
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,23 +30,43 @@ CLASS_MAPPING = {
|
||||
|
||||
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
|
||||
|
||||
from zigpy.zcl.clusters.general import OnOff
|
||||
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)
|
||||
"""Old way of setting up Zigbee Home Automation binary sensors."""
|
||||
pass
|
||||
|
||||
|
||||
async def _async_setup_iaszone(hass, config, async_add_entities,
|
||||
discovery_info):
|
||||
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."""
|
||||
entities = []
|
||||
for discovery_info in discovery_infos:
|
||||
from zigpy.zcl.clusters.general import OnOff
|
||||
from zigpy.zcl.clusters.security import IasZone
|
||||
if IasZone.cluster_id in discovery_info['in_clusters']:
|
||||
entities.append(await _async_setup_iaszone(discovery_info))
|
||||
elif OnOff.cluster_id in discovery_info['out_clusters']:
|
||||
entities.append(await _async_setup_remote(discovery_info))
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
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]
|
||||
@@ -58,13 +82,10 @@ 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 BinarySensor(device_class, **discovery_info)
|
||||
|
||||
|
||||
async def _async_setup_remote(hass, config, async_add_entities,
|
||||
discovery_info):
|
||||
|
||||
async def _async_setup_remote(discovery_info):
|
||||
remote = Remote(**discovery_info)
|
||||
|
||||
if discovery_info['new_join']:
|
||||
@@ -72,21 +93,21 @@ async def _async_setup_remote(hass, config, async_add_entities,
|
||||
out_clusters = discovery_info['out_clusters']
|
||||
if OnOff.cluster_id in out_clusters:
|
||||
cluster = out_clusters[OnOff.cluster_id]
|
||||
await zha.configure_reporting(
|
||||
await helpers.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(
|
||||
await helpers.configure_reporting(
|
||||
remote.entity_id, cluster, 0, min_report=1, max_report=600,
|
||||
reportable_change=1
|
||||
)
|
||||
|
||||
async_add_entities([remote], update_before_add=True)
|
||||
return remote
|
||||
|
||||
|
||||
class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
class BinarySensor(ZhaEntity, BinarySensorDevice):
|
||||
"""The ZHA Binary Sensor."""
|
||||
|
||||
_domain = DOMAIN
|
||||
@@ -130,16 +151,16 @@ class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
"""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(ZhaEntity, BinarySensorDevice):
|
||||
"""ZHA switch/remote controller/button."""
|
||||
|
||||
_domain = DOMAIN
|
||||
@@ -252,7 +273,7 @@ class Remote(zha.Entity, BinarySensorDevice):
|
||||
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,
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.const import (
|
||||
CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME,
|
||||
CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT)
|
||||
|
||||
REQUIREMENTS = ['blinkpy==0.10.1']
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -60,13 +60,20 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
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):
|
||||
|
||||
@@ -89,13 +89,12 @@ class MqttCamera(Camera):
|
||||
"""Return a unique ID."""
|
||||
return self._unique_id
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe MQTT events."""
|
||||
@callback
|
||||
def message_received(topic, payload, qos):
|
||||
"""Handle new MQTT messages."""
|
||||
self._last_image = payload
|
||||
|
||||
return mqtt.async_subscribe(
|
||||
await mqtt.async_subscribe(
|
||||
self.hass, self._topic, message_received, self._qos, None)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"no_devices_found": "No se encontraron dispositivos de Google Cast en la red.",
|
||||
"single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast."
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "\u00bfQuieres configurar Google Cast?",
|
||||
"title": "Google Cast"
|
||||
}
|
||||
},
|
||||
"title": "Google Cast"
|
||||
}
|
||||
}
|
||||
@@ -249,9 +249,11 @@ class ClimateDevice(Entity):
|
||||
self.hass, self.target_temperature_low, self.temperature_unit,
|
||||
self.precision)
|
||||
|
||||
if self.current_humidity is not None:
|
||||
data[ATTR_CURRENT_HUMIDITY] = self.current_humidity
|
||||
|
||||
if supported_features & SUPPORT_TARGET_HUMIDITY:
|
||||
data[ATTR_HUMIDITY] = self.target_humidity
|
||||
data[ATTR_CURRENT_HUMIDITY] = self.current_humidity
|
||||
|
||||
if supported_features & SUPPORT_TARGET_HUMIDITY_LOW:
|
||||
data[ATTR_MIN_HUMIDITY] = self.min_humidity
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.const import (
|
||||
ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pydaikin==0.4']
|
||||
REQUIREMENTS = ['pydaikin==0.8']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,7 +82,6 @@ class DaikinClimate(ClimateDevice):
|
||||
from pydaikin import appliance
|
||||
|
||||
self._api = api
|
||||
self._force_refresh = False
|
||||
self._list = {
|
||||
ATTR_OPERATION_MODE: list(HA_STATE_TO_DAIKIN),
|
||||
ATTR_FAN_MODE: list(
|
||||
@@ -102,19 +101,11 @@ class DaikinClimate(ClimateDevice):
|
||||
self._supported_features = SUPPORT_TARGET_TEMPERATURE \
|
||||
| SUPPORT_OPERATION_MODE
|
||||
|
||||
daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE]
|
||||
if self._api.device.values.get(daikin_attr) is not None:
|
||||
if self._api.device.support_fan_mode:
|
||||
self._supported_features |= SUPPORT_FAN_MODE
|
||||
else:
|
||||
# even devices without support must have a default valid value
|
||||
self._api.device.values[daikin_attr] = 'A'
|
||||
|
||||
daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE]
|
||||
if self._api.device.values.get(daikin_attr) is not None:
|
||||
if self._api.device.support_swing_mode:
|
||||
self._supported_features |= SUPPORT_SWING_MODE
|
||||
else:
|
||||
# even devices without support must have a default valid value
|
||||
self._api.device.values[daikin_attr] = '0'
|
||||
|
||||
def get(self, key):
|
||||
"""Retrieve device settings from API library cache."""
|
||||
@@ -189,7 +180,6 @@ class DaikinClimate(ClimateDevice):
|
||||
_LOGGER.error("Invalid temperature %s", value)
|
||||
|
||||
if values:
|
||||
self._force_refresh = True
|
||||
self._api.device.set(values)
|
||||
|
||||
@property
|
||||
@@ -202,6 +192,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."""
|
||||
@@ -270,5 +265,4 @@ class DaikinClimate(ClimateDevice):
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
self._api.update(no_throttle=self._force_refresh)
|
||||
self._force_refresh = False
|
||||
self._api.update()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,12 +17,13 @@ from homeassistant.components.climate import (
|
||||
SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import (
|
||||
STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN)
|
||||
SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN, PRECISION_HALVES,
|
||||
PRECISION_TENTHS, PRECISION_WHOLE)
|
||||
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__)
|
||||
|
||||
@@ -43,6 +44,7 @@ CONF_HOT_TOLERANCE = 'hot_tolerance'
|
||||
CONF_KEEP_ALIVE = 'keep_alive'
|
||||
CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode'
|
||||
CONF_AWAY_TEMP = 'away_temp'
|
||||
CONF_PRECISION = 'precision'
|
||||
SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE |
|
||||
SUPPORT_OPERATION_MODE)
|
||||
|
||||
@@ -63,7 +65,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional(CONF_INITIAL_OPERATION_MODE):
|
||||
vol.In([STATE_AUTO, STATE_OFF]),
|
||||
vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float)
|
||||
vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_PRECISION): vol.In(
|
||||
[PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
|
||||
})
|
||||
|
||||
|
||||
@@ -83,20 +87,22 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
keep_alive = config.get(CONF_KEEP_ALIVE)
|
||||
initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE)
|
||||
away_temp = config.get(CONF_AWAY_TEMP)
|
||||
precision = config.get(CONF_PRECISION)
|
||||
|
||||
async_add_entities([GenericThermostat(
|
||||
hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp,
|
||||
target_temp, ac_mode, min_cycle_duration, cold_tolerance,
|
||||
hot_tolerance, keep_alive, initial_operation_mode, away_temp)])
|
||||
hot_tolerance, keep_alive, initial_operation_mode, away_temp,
|
||||
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,
|
||||
min_temp, max_temp, target_temp, ac_mode, min_cycle_duration,
|
||||
cold_tolerance, hot_tolerance, keep_alive,
|
||||
initial_operation_mode, away_temp):
|
||||
initial_operation_mode, away_temp, precision):
|
||||
"""Initialize the thermostat."""
|
||||
self.hass = hass
|
||||
self._name = name
|
||||
@@ -109,6 +115,7 @@ class GenericThermostat(ClimateDevice):
|
||||
self._initial_operation_mode = initial_operation_mode
|
||||
self._saved_target_temp = target_temp if target_temp is not None \
|
||||
else away_temp
|
||||
self._temp_precision = precision
|
||||
if self.ac_mode:
|
||||
self._current_operation = STATE_COOL
|
||||
self._operation_list = [STATE_COOL, STATE_OFF]
|
||||
@@ -148,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:
|
||||
@@ -202,6 +210,13 @@ class GenericThermostat(ClimateDevice):
|
||||
"""Return the name of the thermostat."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
"""Return the precision of the system."""
|
||||
if self._temp_precision is not None:
|
||||
return self._temp_precision
|
||||
return super().precision
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
|
||||
@@ -7,17 +7,16 @@ https://home-assistant.io/components/climate.homematic/
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
|
||||
ClimateDevice)
|
||||
STATE_AUTO, STATE_MANUAL, SUPPORT_OPERATION_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE, ClimateDevice)
|
||||
from homeassistant.components.homematic import (
|
||||
ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT, HMDevice)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, TEMP_CELSIUS
|
||||
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
|
||||
|
||||
DEPENDENCIES = ['homematic']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STATE_MANUAL = 'manual'
|
||||
STATE_BOOST = 'boost'
|
||||
STATE_COMFORT = 'comfort'
|
||||
STATE_LOWERING = 'lowering'
|
||||
@@ -41,7 +40,7 @@ HM_HUMI_MAP = [
|
||||
]
|
||||
|
||||
HM_CONTROL_MODE = 'CONTROL_MODE'
|
||||
HM_IP_CONTROL_MODE = 'SET_POINT_MODE'
|
||||
HMIP_CONTROL_MODE = 'SET_POINT_MODE'
|
||||
|
||||
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
|
||||
|
||||
@@ -78,21 +77,17 @@ class HMThermostat(HMDevice, ClimateDevice):
|
||||
if HM_CONTROL_MODE not in self._data:
|
||||
return None
|
||||
|
||||
set_point_mode = self._data.get('SET_POINT_MODE', -1)
|
||||
control_mode = self._data.get('CONTROL_MODE', -1)
|
||||
boost_mode = self._data.get('BOOST_MODE', False)
|
||||
|
||||
# boost mode is active
|
||||
if boost_mode:
|
||||
if self._data.get('BOOST_MODE', False):
|
||||
return STATE_BOOST
|
||||
|
||||
# HM ip etrv 2 uses the set_point_mode to say if its
|
||||
# HmIP uses the set_point_mode to say if its
|
||||
# auto or manual
|
||||
if not set_point_mode == -1:
|
||||
code = set_point_mode
|
||||
if HMIP_CONTROL_MODE in self._data:
|
||||
code = self._data[HMIP_CONTROL_MODE]
|
||||
# Other devices use the control_mode
|
||||
else:
|
||||
code = control_mode
|
||||
code = self._data['CONTROL_MODE']
|
||||
|
||||
# get the name of the mode
|
||||
name = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][code]
|
||||
@@ -101,12 +96,15 @@ class HMThermostat(HMDevice, ClimateDevice):
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""Return the list of available operation modes."""
|
||||
op_list = []
|
||||
# HMIP use set_point_mode for operation
|
||||
if HMIP_CONTROL_MODE in self._data:
|
||||
return [STATE_MANUAL, STATE_AUTO, STATE_BOOST]
|
||||
|
||||
# HM
|
||||
op_list = []
|
||||
for mode in self._hmdevice.ACTIONNODE:
|
||||
if mode in HM_STATE_MAP:
|
||||
op_list.append(HM_STATE_MAP.get(mode))
|
||||
|
||||
return op_list
|
||||
|
||||
@property
|
||||
@@ -157,11 +155,11 @@ class HMThermostat(HMDevice, ClimateDevice):
|
||||
def _init_data_struct(self):
|
||||
"""Generate a data dict (self._data) from the Homematic metadata."""
|
||||
self._state = next(iter(self._hmdevice.WRITENODE.keys()))
|
||||
self._data[self._state] = STATE_UNKNOWN
|
||||
self._data[self._state] = None
|
||||
|
||||
if HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE or \
|
||||
HM_IP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE:
|
||||
self._data[HM_CONTROL_MODE] = STATE_UNKNOWN
|
||||
HMIP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE:
|
||||
self._data[HM_CONTROL_MODE] = None
|
||||
|
||||
for node in self._hmdevice.SENSORNODE.keys():
|
||||
self._data[node] = STATE_UNKNOWN
|
||||
self._data[node] = None
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -88,6 +88,12 @@ class MelissaClimate(ClimateDevice):
|
||||
if self._data:
|
||||
return self._data[self._api.TEMP]
|
||||
|
||||
@property
|
||||
def current_humidity(self):
|
||||
"""Return the current humidity value."""
|
||||
if self._data:
|
||||
return self._data[self._api.HUMIDITY]
|
||||
|
||||
@property
|
||||
def target_temperature_step(self):
|
||||
"""Return the supported step of target temperature."""
|
||||
@@ -113,8 +119,9 @@ class MelissaClimate(ClimateDevice):
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
if self._cur_settings is not None:
|
||||
return self._cur_settings[self._api.TEMP]
|
||||
if self._cur_settings is None:
|
||||
return None
|
||||
return self._cur_settings[self._api.TEMP]
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
||||
@@ -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.2']
|
||||
REQUIREMENTS = ['millheater==0.2.9']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,8 +32,7 @@ MIN_TEMP = 5
|
||||
SERVICE_SET_ROOM_TEMP = 'mill_set_room_temperature'
|
||||
|
||||
SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE |
|
||||
SUPPORT_FAN_MODE | SUPPORT_ON_OFF |
|
||||
SUPPORT_OPERATION_MODE)
|
||||
SUPPORT_FAN_MODE)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
@@ -92,12 +91,14 @@ class MillHeater(ClimateDevice):
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_FLAGS
|
||||
if self._heater.is_gen1:
|
||||
return SUPPORT_FLAGS
|
||||
return SUPPORT_FLAGS | SUPPORT_ON_OFF | SUPPORT_OPERATION_MODE
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return self._heater.device_status == 0 # weird api choice
|
||||
return self._heater.available
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
@@ -112,16 +113,18 @@ class MillHeater(ClimateDevice):
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
if self._heater.room:
|
||||
room = self._heater.room.name
|
||||
else:
|
||||
room = "Independent device"
|
||||
return {
|
||||
"room": room,
|
||||
res = {
|
||||
"open_window": self._heater.open_window,
|
||||
"heating": self._heater.is_heating,
|
||||
"controlled_by_tibber": self._heater.tibber_control,
|
||||
"heater_generation": 1 if self._heater.is_gen1 else 2,
|
||||
}
|
||||
if self._heater.room:
|
||||
res['room'] = self._heater.room.name
|
||||
res['avg_room_temp'] = self._heater.room.avg_temp
|
||||
else:
|
||||
res['room'] = "Independent device"
|
||||
return res
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
@@ -156,6 +159,8 @@ class MillHeater(ClimateDevice):
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if heater is on."""
|
||||
if self._heater.is_gen1:
|
||||
return True
|
||||
return self._heater.power_status == 1
|
||||
|
||||
@property
|
||||
@@ -176,6 +181,8 @@ class MillHeater(ClimateDevice):
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""List of available operation modes."""
|
||||
if self._heater.is_gen1:
|
||||
return None
|
||||
return [STATE_HEAT, STATE_OFF]
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
@@ -210,7 +217,7 @@ class MillHeater(ClimateDevice):
|
||||
"""Set operation mode."""
|
||||
if operation_mode == STATE_HEAT:
|
||||
await self.async_turn_on()
|
||||
elif operation_mode == STATE_OFF:
|
||||
elif operation_mode == STATE_OFF and not self._heater.is_gen1:
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
_LOGGER.error("Unrecognized operation mode: %s", operation_mode)
|
||||
|
||||
@@ -22,7 +22,8 @@ from homeassistant.const import (
|
||||
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,
|
||||
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 +78,18 @@ CONF_MIN_TEMP = 'min_temp'
|
||||
CONF_MAX_TEMP = 'max_temp'
|
||||
CONF_TEMP_STEP = 'temp_step'
|
||||
|
||||
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,
|
||||
@@ -153,69 +166,10 @@ 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,
|
||||
)])
|
||||
|
||||
@@ -223,54 +177,103 @@ async def _async_setup_entity(hass, config, async_add_entities,
|
||||
class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, 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._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)
|
||||
|
||||
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)
|
||||
|
||||
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 +290,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 +302,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 +329,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 +342,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 +362,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 +398,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 +426,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 +442,19 @@ 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."""
|
||||
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 +464,7 @@ 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 temperature_unit(self):
|
||||
@@ -465,12 +489,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 +519,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 +532,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 +556,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 +569,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 +600,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 +620,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 +633,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 +644,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 +656,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 +702,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)
|
||||
|
||||
@@ -168,18 +168,14 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
if self._mode != NEST_MODE_HEAT_COOL and \
|
||||
self._mode != STATE_ECO and \
|
||||
not self.is_away_mode_on:
|
||||
if self._mode not in (NEST_MODE_HEAT_COOL, STATE_ECO):
|
||||
return self._target_temperature
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_low(self):
|
||||
"""Return the lower bound temperature we try to reach."""
|
||||
if (self.is_away_mode_on or self._mode == STATE_ECO) and \
|
||||
self._eco_temperature[0]:
|
||||
# eco_temperature is always a low, high tuple
|
||||
if self._mode == STATE_ECO:
|
||||
return self._eco_temperature[0]
|
||||
if self._mode == NEST_MODE_HEAT_COOL:
|
||||
return self._target_temperature[0]
|
||||
@@ -188,9 +184,7 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def target_temperature_high(self):
|
||||
"""Return the upper bound temperature we try to reach."""
|
||||
if (self.is_away_mode_on or self._mode == STATE_ECO) and \
|
||||
self._eco_temperature[1]:
|
||||
# eco_temperature is always a low, high tuple
|
||||
if self._mode == STATE_ECO:
|
||||
return self._eco_temperature[1]
|
||||
if self._mode == NEST_MODE_HEAT_COOL:
|
||||
return self._target_temperature[1]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -7,18 +7,17 @@ https://home-assistant.io/components/climate.velbus/
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice)
|
||||
STATE_HEAT, SUPPORT_TARGET_TEMPERATURE, ClimateDevice)
|
||||
from homeassistant.components.velbus import (
|
||||
DOMAIN as VELBUS_DOMAIN, VelbusEntity)
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
from homeassistant.const import (
|
||||
TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['velbus']
|
||||
|
||||
OPERATION_LIST = ['comfort', 'day', 'night', 'safe']
|
||||
|
||||
SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE)
|
||||
SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
@@ -47,7 +46,9 @@ class VelbusClimate(VelbusEntity, ClimateDevice):
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit this state is expressed in."""
|
||||
return self._module.get_unit(self._channel)
|
||||
if self._module.get_unit(self._channel) == '°C':
|
||||
return TEMP_CELSIUS
|
||||
return TEMP_FAHRENHEIT
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
@@ -56,26 +57,18 @@ class VelbusClimate(VelbusEntity, ClimateDevice):
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
return self._module.get_climate_mode()
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""Return the list of available operation modes."""
|
||||
return OPERATION_LIST
|
||||
"""Return current operation."""
|
||||
return STATE_HEAT
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._module.get_climate_target()
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set new target operation mode."""
|
||||
self._module.set_mode(operation_mode)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperatures."""
|
||||
if kwargs.get(ATTR_TEMPERATURE) is not None:
|
||||
self._module.set_temp(kwargs.get(ATTR_TEMPERATURE))
|
||||
self.schedule_update_ha_state()
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temp is None:
|
||||
return
|
||||
self._module.set_temp(temp)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -12,24 +12,20 @@ import os
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME)
|
||||
EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION,
|
||||
CONF_MODE, CONF_NAME)
|
||||
from homeassistant.helpers import entityfilter, config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
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
|
||||
from . import http_api, iot, auth_api, prefs, cloudhooks
|
||||
from .const import CONFIG_DIR, DOMAIN, SERVERS
|
||||
|
||||
REQUIREMENTS = ['warrant==0.6.1']
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_ENABLE_ALEXA = 'alexa_enabled'
|
||||
STORAGE_ENABLE_GOOGLE = 'google_enabled'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_UNDEF = object()
|
||||
|
||||
CONF_ALEXA = 'alexa'
|
||||
CONF_ALIASES = 'aliases'
|
||||
@@ -41,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']
|
||||
@@ -68,7 +65,7 @@ ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({
|
||||
})
|
||||
|
||||
GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
|
||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}
|
||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
@@ -82,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,
|
||||
}),
|
||||
@@ -117,19 +115,19 @@ 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
|
||||
self.alexa_config = alexa
|
||||
self.google_actions_user_conf = google_actions
|
||||
self._gactions_config = None
|
||||
self._prefs = None
|
||||
self.prefs = prefs.CloudPreferences(hass)
|
||||
self.id_token = None
|
||||
self.access_token = None
|
||||
self.refresh_token = None
|
||||
self.iot = iot.CloudIoT(self)
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self.cloudhooks = cloudhooks.Cloudhooks(self)
|
||||
|
||||
if mode == MODE_DEV:
|
||||
self.cognito_client_id = cognito_client_id
|
||||
@@ -138,6 +136,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]
|
||||
@@ -148,6 +147,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):
|
||||
@@ -184,26 +184,20 @@ class Cloud:
|
||||
|
||||
def should_expose(entity):
|
||||
"""If an entity should be exposed."""
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
return conf['filter'](entity.entity_id)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
return self._gactions_config
|
||||
|
||||
@property
|
||||
def alexa_enabled(self):
|
||||
"""Return if Alexa is enabled."""
|
||||
return self._prefs[STORAGE_ENABLE_ALEXA]
|
||||
|
||||
@property
|
||||
def google_enabled(self):
|
||||
"""Return if Google is enabled."""
|
||||
return self._prefs[STORAGE_ENABLE_GOOGLE]
|
||||
|
||||
def path(self, *parts):
|
||||
"""Get config path inside cloud dir.
|
||||
|
||||
@@ -243,20 +237,6 @@ class Cloud:
|
||||
|
||||
async def async_start(self, _):
|
||||
"""Start the cloud component."""
|
||||
prefs = await self._store.async_load()
|
||||
if prefs is None:
|
||||
prefs = {}
|
||||
if self.mode not in prefs:
|
||||
# Default to True if already logged in to make this not a
|
||||
# breaking change.
|
||||
enabled = await self.hass.async_add_executor_job(
|
||||
os.path.isfile, self.user_info_path)
|
||||
prefs = {
|
||||
STORAGE_ENABLE_ALEXA: enabled,
|
||||
STORAGE_ENABLE_GOOGLE: enabled,
|
||||
}
|
||||
self._prefs = prefs
|
||||
|
||||
def load_config():
|
||||
"""Load config."""
|
||||
# Ensure config dir exists
|
||||
@@ -272,6 +252,7 @@ class Cloud:
|
||||
return json.loads(file.read())
|
||||
|
||||
info = await self.hass.async_add_job(load_config)
|
||||
await self.prefs.async_initialize()
|
||||
|
||||
if info is None:
|
||||
return
|
||||
@@ -282,15 +263,6 @@ class Cloud:
|
||||
|
||||
self.hass.add_job(self.iot.connect())
|
||||
|
||||
async def update_preferences(self, *, google_enabled=_UNDEF,
|
||||
alexa_enabled=_UNDEF):
|
||||
"""Update user preferences."""
|
||||
if google_enabled is not _UNDEF:
|
||||
self._prefs[STORAGE_ENABLE_GOOGLE] = google_enabled
|
||||
if alexa_enabled is not _UNDEF:
|
||||
self._prefs[STORAGE_ENABLE_ALEXA] = alexa_enabled
|
||||
await self._store.async_save(self._prefs)
|
||||
|
||||
def _decode_claims(self, token): # pylint: disable=no-self-use
|
||||
"""Decode the claims in a token."""
|
||||
from jose import jwt
|
||||
|
||||
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()
|
||||
@@ -3,6 +3,11 @@ DOMAIN = 'cloud'
|
||||
CONFIG_DIR = '.cloud'
|
||||
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': {
|
||||
'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u',
|
||||
@@ -12,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
|
||||
|
||||
@@ -15,7 +16,9 @@ from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.google_assistant import smart_home as google_sh
|
||||
|
||||
from . import auth_api
|
||||
from .const import DOMAIN, REQUEST_TIMEOUT
|
||||
from .const import (
|
||||
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK)
|
||||
from .iot import STATE_DISCONNECTED, STATE_CONNECTED
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -30,8 +33,9 @@ SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
WS_TYPE_UPDATE_PREFS = 'cloud/update_prefs'
|
||||
SCHEMA_WS_UPDATE_PREFS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_UPDATE_PREFS,
|
||||
vol.Optional('google_enabled'): bool,
|
||||
vol.Optional('alexa_enabled'): bool,
|
||||
vol.Optional(PREF_ENABLE_GOOGLE): bool,
|
||||
vol.Optional(PREF_ENABLE_ALEXA): bool,
|
||||
vol.Optional(PREF_GOOGLE_ALLOW_UNLOCK): bool,
|
||||
})
|
||||
|
||||
|
||||
@@ -41,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(
|
||||
@@ -55,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)
|
||||
@@ -73,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."""
|
||||
@@ -237,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()
|
||||
|
||||
@@ -274,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.update_preferences(**changes)
|
||||
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):
|
||||
@@ -308,10 +379,9 @@ def _account_data(cloud):
|
||||
'logged_in': True,
|
||||
'email': claims['email'],
|
||||
'cloud': cloud.iot.state,
|
||||
'google_enabled': cloud.google_enabled,
|
||||
'prefs': cloud.prefs.as_dict(),
|
||||
'google_entities': cloud.google_actions_user_conf['filter'].config,
|
||||
'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES),
|
||||
'alexa_enabled': cloud.alexa_enabled,
|
||||
'alexa_entities': cloud.alexa_config.should_expose.config,
|
||||
'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -229,7 +296,7 @@ def async_handle_alexa(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for Alexa."""
|
||||
result = yield from alexa.async_handle_message(
|
||||
hass, cloud.alexa_config, payload,
|
||||
enabled=cloud.alexa_enabled)
|
||||
enabled=cloud.prefs.alexa_enabled)
|
||||
return result
|
||||
|
||||
|
||||
@@ -237,7 +304,7 @@ def async_handle_alexa(hass, cloud, payload):
|
||||
@asyncio.coroutine
|
||||
def async_handle_google_actions(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for Google Actions."""
|
||||
if not cloud.google_enabled:
|
||||
if not cloud.prefs.google_enabled:
|
||||
return ga.turned_off_response(payload)
|
||||
|
||||
result = yield from ga.async_handle_message(
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
70
homeassistant/components/cloud/prefs.py
Normal file
70
homeassistant/components/cloud/prefs.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Preference management for cloud."""
|
||||
from .const import (
|
||||
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS)
|
||||
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
_UNDEF = object()
|
||||
|
||||
|
||||
class CloudPreferences:
|
||||
"""Handle cloud preferences."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize cloud prefs."""
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self._prefs = None
|
||||
|
||||
async def async_initialize(self):
|
||||
"""Finish initializing the preferences."""
|
||||
prefs = await self._store.async_load()
|
||||
|
||||
if prefs is None:
|
||||
prefs = {
|
||||
PREF_ENABLE_ALEXA: True,
|
||||
PREF_ENABLE_GOOGLE: True,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK: False,
|
||||
PREF_CLOUDHOOKS: {}
|
||||
}
|
||||
|
||||
self._prefs = prefs
|
||||
|
||||
async def async_update(self, *, google_enabled=_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
|
||||
|
||||
await self._store.async_save(self._prefs)
|
||||
|
||||
def as_dict(self):
|
||||
"""Return dictionary version."""
|
||||
return self._prefs
|
||||
|
||||
@property
|
||||
def alexa_enabled(self):
|
||||
"""Return if Alexa is enabled."""
|
||||
return self._prefs[PREF_ENABLE_ALEXA]
|
||||
|
||||
@property
|
||||
def google_enabled(self):
|
||||
"""Return if Google is enabled."""
|
||||
return self._prefs[PREF_ENABLE_GOOGLE]
|
||||
|
||||
@property
|
||||
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, {})
|
||||
@@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DOMAIN = 'coinbase'
|
||||
|
||||
CONF_API_SECRET = 'api_secret'
|
||||
CONF_ACCOUNT_CURRENCIES = 'account_balance_currencies'
|
||||
CONF_EXCHANGE_CURRENCIES = 'exchange_rate_currencies'
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
|
||||
@@ -31,6 +32,8 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
vol.Required(CONF_API_SECRET): cv.string,
|
||||
vol.Optional(CONF_ACCOUNT_CURRENCIES):
|
||||
vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]):
|
||||
vol.All(cv.ensure_list, [cv.string])
|
||||
})
|
||||
@@ -45,6 +48,7 @@ def setup(hass, config):
|
||||
"""
|
||||
api_key = config[DOMAIN].get(CONF_API_KEY)
|
||||
api_secret = config[DOMAIN].get(CONF_API_SECRET)
|
||||
account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES)
|
||||
exchange_currencies = config[DOMAIN].get(CONF_EXCHANGE_CURRENCIES)
|
||||
|
||||
hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData(
|
||||
@@ -53,7 +57,13 @@ def setup(hass, config):
|
||||
if not hasattr(coinbase_data, 'accounts'):
|
||||
return False
|
||||
for account in coinbase_data.accounts.data:
|
||||
load_platform(hass, 'sensor', DOMAIN, {'account': account}, config)
|
||||
if (account_currencies is None or
|
||||
account.currency in account_currencies):
|
||||
load_platform(hass,
|
||||
'sensor',
|
||||
DOMAIN,
|
||||
{'account': account},
|
||||
config)
|
||||
for currency in exchange_currencies:
|
||||
if currency not in coinbase_data.exchange_rates.rates:
|
||||
_LOGGER.warning("Currency %s not found", currency)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -10,9 +10,8 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import async_get_last_state
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -86,7 +85,7 @@ async def async_setup(hass, config):
|
||||
return True
|
||||
|
||||
|
||||
class Counter(Entity):
|
||||
class Counter(RestoreEntity):
|
||||
"""Representation of a counter."""
|
||||
|
||||
def __init__(self, object_id, name, initial, restore, step, icon):
|
||||
@@ -128,10 +127,11 @@ class Counter(Entity):
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Call when entity about to be added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
# __init__ will set self._state to self._initial, only override
|
||||
# if needed.
|
||||
if self._restore:
|
||||
state = await async_get_last_state(self.hass, self.entity_id)
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None:
|
||||
self._state = int(state.state)
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/cover.deconz/
|
||||
"""
|
||||
from homeassistant.components.deconz.const import (
|
||||
COVER_TYPES, DAMPERS, DOMAIN as DATA_DECONZ, DECONZ_DOMAIN, WINDOW_COVERS)
|
||||
COVER_TYPES, DAMPERS, DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN,
|
||||
WINDOW_COVERS)
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP,
|
||||
SUPPORT_SET_POSITION)
|
||||
@@ -29,6 +30,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
|
||||
Covers are based on same device class as lights in deCONZ.
|
||||
"""
|
||||
gateway = hass.data[DECONZ_DOMAIN]
|
||||
|
||||
@callback
|
||||
def async_add_cover(lights):
|
||||
"""Add cover from deCONZ."""
|
||||
@@ -36,23 +39,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
for light in lights:
|
||||
if light.type in COVER_TYPES:
|
||||
if light.modelid in ZIGBEE_SPEC:
|
||||
entities.append(DeconzCoverZigbeeSpec(light))
|
||||
entities.append(DeconzCoverZigbeeSpec(light, gateway))
|
||||
else:
|
||||
entities.append(DeconzCover(light))
|
||||
entities.append(DeconzCover(light, gateway))
|
||||
async_add_entities(entities, True)
|
||||
|
||||
hass.data[DATA_DECONZ].listeners.append(
|
||||
gateway.listeners.append(
|
||||
async_dispatcher_connect(hass, 'deconz_new_light', async_add_cover))
|
||||
|
||||
async_add_cover(hass.data[DATA_DECONZ].api.lights.values())
|
||||
async_add_cover(gateway.api.lights.values())
|
||||
|
||||
|
||||
class DeconzCover(CoverDevice):
|
||||
"""Representation of a deCONZ cover."""
|
||||
|
||||
def __init__(self, cover):
|
||||
def __init__(self, cover, gateway):
|
||||
"""Set up cover and add update callback to get data from websocket."""
|
||||
self._cover = cover
|
||||
self.gateway = gateway
|
||||
self.unsub_dispatcher = None
|
||||
|
||||
self._features = SUPPORT_OPEN
|
||||
self._features |= SUPPORT_CLOSE
|
||||
self._features |= SUPPORT_STOP
|
||||
@@ -61,11 +67,14 @@ class DeconzCover(CoverDevice):
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to covers events."""
|
||||
self._cover.register_async_callback(self.async_update_callback)
|
||||
self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \
|
||||
self._cover.deconz_id
|
||||
self.gateway.deconz_ids[self.entity_id] = self._cover.deconz_id
|
||||
self.unsub_dispatcher = async_dispatcher_connect(
|
||||
self.hass, DECONZ_REACHABLE, self.async_update_callback)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect cover object when removed."""
|
||||
if self.unsub_dispatcher is not None:
|
||||
self.unsub_dispatcher()
|
||||
self._cover.remove_callback(self.async_update_callback)
|
||||
self._cover = None
|
||||
|
||||
@@ -112,7 +121,7 @@ class DeconzCover(CoverDevice):
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if light is available."""
|
||||
return self._cover.reachable
|
||||
return self.gateway.available and self._cover.reachable
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -150,7 +159,7 @@ class DeconzCover(CoverDevice):
|
||||
self._cover.uniqueid.count(':') != 7):
|
||||
return None
|
||||
serial = self._cover.uniqueid.split('-', 1)[0]
|
||||
bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid
|
||||
bridgeid = self.gateway.api.config.bridgeid
|
||||
return {
|
||||
'connections': {(CONNECTION_ZIGBEE, serial)},
|
||||
'identifiers': {(DECONZ_DOMAIN, serial)},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user