mirror of
https://github.com/home-assistant/core.git
synced 2026-01-04 14:55:39 +01:00
Compare commits
1437 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122e21a3c4 | ||
|
|
33a54043e9 | ||
|
|
d09ee8ac82 | ||
|
|
1ca2e5226b | ||
|
|
1e28c752e1 | ||
|
|
677714ecab | ||
|
|
e2aadc3227 | ||
|
|
c45b240026 | ||
|
|
32d652884b | ||
|
|
3528f8e647 | ||
|
|
3e6cf8f59f | ||
|
|
c93879074b | ||
|
|
f476d781ec | ||
|
|
7bfe0e1c00 | ||
|
|
fb39641eef | ||
|
|
b7e03f6973 | ||
|
|
7597e30efb | ||
|
|
51dbc988f9 | ||
|
|
71333a15f7 | ||
|
|
7b68f344e3 | ||
|
|
824e59499f | ||
|
|
ff9377d1d9 | ||
|
|
cf0d0fb33e | ||
|
|
3aaf619fc3 | ||
|
|
e375b63902 | ||
|
|
61a2d09342 | ||
|
|
345c886dec | ||
|
|
b9043ef7a7 | ||
|
|
356040d506 | ||
|
|
0431e38aa2 | ||
|
|
4c32ad3b48 | ||
|
|
7d68ec1110 | ||
|
|
c352b6fa59 | ||
|
|
0bd94d8b56 | ||
|
|
3e2a9afff0 | ||
|
|
d4b239d1d4 | ||
|
|
b2a9e203f2 | ||
|
|
dc1534c6d1 | ||
|
|
589554ad16 | ||
|
|
33d6c99f19 | ||
|
|
1f74adae2a | ||
|
|
7a77951bb4 | ||
|
|
ad47ece5c6 | ||
|
|
5ee4718e24 | ||
|
|
a5cb4e6c2b | ||
|
|
4fd2f773ad | ||
|
|
564ad7e22a | ||
|
|
329d9dfc06 | ||
|
|
2258c56d34 | ||
|
|
e90abf1007 | ||
|
|
eaee55175b | ||
|
|
539b86e079 | ||
|
|
127395ae8d | ||
|
|
eca1f050cd | ||
|
|
0d681b0ba6 | ||
|
|
94d38a1c6b | ||
|
|
cfd1aec741 | ||
|
|
eec6722cf4 | ||
|
|
fce275d29c | ||
|
|
5eda5f2f7b | ||
|
|
5ab580ab34 | ||
|
|
5613816476 | ||
|
|
e9c7fe184d | ||
|
|
41bb4760f7 | ||
|
|
ee3f17d5c7 | ||
|
|
18d37ff0fd | ||
|
|
7fe0d8b2f4 | ||
|
|
8b42d0c471 | ||
|
|
213171769d | ||
|
|
d5813cf167 | ||
|
|
3e59ffb33a | ||
|
|
a0a54dfd5b | ||
|
|
3bfe9e757e | ||
|
|
9fdf123a2f | ||
|
|
aeaf694552 | ||
|
|
3d1c8ee467 | ||
|
|
98b92c78c0 | ||
|
|
2ac16b12c1 | ||
|
|
df67093441 | ||
|
|
c475a876ce | ||
|
|
90c18d1c15 | ||
|
|
78b6439ee6 | ||
|
|
092c146eae | ||
|
|
44a98fb77a | ||
|
|
03d93bea34 | ||
|
|
9dbac9b033 | ||
|
|
9e86f0498b | ||
|
|
03de658d4d | ||
|
|
3ea8c25e1f | ||
|
|
c6ccbed828 | ||
|
|
e58836f99f | ||
|
|
874225dd67 | ||
|
|
0d0d5c8c2c | ||
|
|
fc6cc22b6d | ||
|
|
39ea9a8c90 | ||
|
|
c7d5f7698e | ||
|
|
9bbd61cbe4 | ||
|
|
7a7a164cb8 | ||
|
|
8379567636 | ||
|
|
93af3c57ff | ||
|
|
d1acb0326c | ||
|
|
35005474f8 | ||
|
|
3a45481b5b | ||
|
|
aa7635398a | ||
|
|
d3658c4af9 | ||
|
|
dfe38b4d5a | ||
|
|
fcb84d951e | ||
|
|
27eede724c | ||
|
|
258beb9cd3 | ||
|
|
da882672bd | ||
|
|
60dfd68083 | ||
|
|
6e4a6cc069 | ||
|
|
a1c524d372 | ||
|
|
3160fa5de8 | ||
|
|
d4b7057a3d | ||
|
|
227a1b919b | ||
|
|
0121e3cb04 | ||
|
|
da108f1999 | ||
|
|
d376049a3f | ||
|
|
7f462ba0ec | ||
|
|
b7ef4dddb4 | ||
|
|
56b0d2e99f | ||
|
|
8e7f783da8 | ||
|
|
1913d07c39 | ||
|
|
2a85ed7236 | ||
|
|
cba3a5b055 | ||
|
|
d2246d5a4f | ||
|
|
72419a1afe | ||
|
|
a7325ebe1f | ||
|
|
27d50d388f | ||
|
|
25712f16b3 | ||
|
|
9e59fc5d05 | ||
|
|
366e270e94 | ||
|
|
4b30cbbf3b | ||
|
|
41ac2a3c73 | ||
|
|
b8257866f5 | ||
|
|
849a93e0a6 | ||
|
|
f918d62571 | ||
|
|
1c251009fe | ||
|
|
201fd4afee | ||
|
|
3e0c6c176a | ||
|
|
44fdfdf695 | ||
|
|
fea1c921fc | ||
|
|
5e3e441aa0 | ||
|
|
a1e6e04a5e | ||
|
|
2002497d09 | ||
|
|
baeb791d84 | ||
|
|
9c9df793b4 | ||
|
|
05922ac56a | ||
|
|
34deaf8849 | ||
|
|
cc38981a38 | ||
|
|
18ce5092b4 | ||
|
|
7f7372198a | ||
|
|
abe61c5529 | ||
|
|
b231fa2616 | ||
|
|
336289d7e7 | ||
|
|
19514ea500 | ||
|
|
00918af94d | ||
|
|
e054e4da1b | ||
|
|
0c945d81c8 | ||
|
|
1ca09ea36f | ||
|
|
8ce2d701c2 | ||
|
|
9c1a539f90 | ||
|
|
0d0bda9658 | ||
|
|
7705666061 | ||
|
|
e82e75baf3 | ||
|
|
481f6e09fa | ||
|
|
72e746d240 | ||
|
|
05c0717167 | ||
|
|
67b5b5bc85 | ||
|
|
d076251b18 | ||
|
|
e59ba28fe6 | ||
|
|
f63dba5521 | ||
|
|
f43d9ba680 | ||
|
|
aec134c47a | ||
|
|
901dd9acca | ||
|
|
7a52bbdf24 | ||
|
|
f2203e52ef | ||
|
|
1586d3000c | ||
|
|
d0aeb90c22 | ||
|
|
cb542a90eb | ||
|
|
3824582e68 | ||
|
|
2682d26f79 | ||
|
|
308b7fb385 | ||
|
|
a88cda44d9 | ||
|
|
08100a485a | ||
|
|
4c1b816bb8 | ||
|
|
1983361373 | ||
|
|
4efe86327d | ||
|
|
ff78a5b04b | ||
|
|
68c21530ca | ||
|
|
453cbb7c60 | ||
|
|
77026a2242 | ||
|
|
501f2b0a93 | ||
|
|
117ea9e553 | ||
|
|
beed82ab12 | ||
|
|
601f2df5a7 | ||
|
|
34d369ba26 | ||
|
|
e2465da7c2 | ||
|
|
6bd120ff1d | ||
|
|
188f5de5ae | ||
|
|
6d33fb2dc8 | ||
|
|
e50fc69c1e | ||
|
|
20f06f4eb9 | ||
|
|
37c8aac76f | ||
|
|
20e562b816 | ||
|
|
a4cec0b871 | ||
|
|
60b45d4ba5 | ||
|
|
1ea8c1ece3 | ||
|
|
44d210698e | ||
|
|
968809c991 | ||
|
|
06af76404f | ||
|
|
b9b8bc678c | ||
|
|
629c4a0bf5 | ||
|
|
00d8907c5e | ||
|
|
3f06b4eb9b | ||
|
|
0db13a99aa | ||
|
|
4e3faf6108 | ||
|
|
9583947012 | ||
|
|
50fb59477a | ||
|
|
20f6cb7cc7 | ||
|
|
6b08e6e769 | ||
|
|
ee696643cd | ||
|
|
a059cc860a | ||
|
|
cfe5db4350 | ||
|
|
dcd7b9a529 | ||
|
|
f858938ada | ||
|
|
e96635b5c1 | ||
|
|
d2d715faa8 | ||
|
|
7a5e828f6b | ||
|
|
99f7b7f42d | ||
|
|
3f97944f6b | ||
|
|
1d6609e386 | ||
|
|
fb15dbf3ba | ||
|
|
c20f147949 | ||
|
|
8ed4d732ac | ||
|
|
a1578e3c6e | ||
|
|
253e787a1b | ||
|
|
0d7ee9b93b | ||
|
|
d6d4ff6888 | ||
|
|
4291bdc6b2 | ||
|
|
7d590a6b93 | ||
|
|
e3e3ed42ec | ||
|
|
e7b8d2e6df | ||
|
|
9944c60311 | ||
|
|
93143384a8 | ||
|
|
8a2bc99f63 | ||
|
|
50266e9b91 | ||
|
|
7ad094b0a7 | ||
|
|
a5715c48a4 | ||
|
|
d0f9d125a7 | ||
|
|
80c77b8696 | ||
|
|
1f73840aab | ||
|
|
fbaa489533 | ||
|
|
cff9b1bf7e | ||
|
|
ce06229c42 | ||
|
|
3acb3a86cf | ||
|
|
db1bda2975 | ||
|
|
2640db1522 | ||
|
|
cf4b72e00e | ||
|
|
5bd9be6252 | ||
|
|
e1084e3953 | ||
|
|
3afc983c05 | ||
|
|
746f4ac158 | ||
|
|
e1501c83f8 | ||
|
|
3bd12fcef6 | ||
|
|
85658b6dd1 | ||
|
|
e61ac1a4a1 | ||
|
|
8fa9992589 | ||
|
|
f96aee2832 | ||
|
|
7a6facc875 | ||
|
|
7ea482cb1d | ||
|
|
a4aa30fc73 | ||
|
|
ba63a6abc0 | ||
|
|
2252f4a257 | ||
|
|
00cba29ae1 | ||
|
|
cd473643fe | ||
|
|
4063b24ddb | ||
|
|
46734b8409 | ||
|
|
b9c80a6bb3 | ||
|
|
a2a447b466 | ||
|
|
669b3458b9 | ||
|
|
bf29cbd381 | ||
|
|
1966597d5e | ||
|
|
4685a2cd97 | ||
|
|
78fcea25bb | ||
|
|
ac3700d1c4 | ||
|
|
03480dc779 | ||
|
|
a5cff9877e | ||
|
|
b29c296ced | ||
|
|
357e5eadb8 | ||
|
|
52e922171d | ||
|
|
97695a30f5 | ||
|
|
1d12c7b0e7 | ||
|
|
15ad82b9bd | ||
|
|
03fb2b32a6 | ||
|
|
87eb6cd25a | ||
|
|
3797b6b012 | ||
|
|
2b0b431a2a | ||
|
|
e75a1690d1 | ||
|
|
444df5b09a | ||
|
|
b31890c4cb | ||
|
|
a5d95dfbdc | ||
|
|
901cfef78e | ||
|
|
2c7d6ee6b5 | ||
|
|
d3791fa45d | ||
|
|
fa81385b5c | ||
|
|
7d852a985c | ||
|
|
976626d0ab | ||
|
|
d705375a9a | ||
|
|
5e8a1496d7 | ||
|
|
bc618af193 | ||
|
|
0e076fb9e7 | ||
|
|
16a58bd1cf | ||
|
|
8be7a0a9b9 | ||
|
|
232076b41d | ||
|
|
efa9c82c38 | ||
|
|
93f45779c6 | ||
|
|
26d39d39ea | ||
|
|
b43c47cb17 | ||
|
|
67d8db2c9f | ||
|
|
3cbf8e4f87 | ||
|
|
f20a3313b0 | ||
|
|
54c3f4f001 | ||
|
|
7289d5b656 | ||
|
|
e77344f029 | ||
|
|
60438067f8 | ||
|
|
9062de0704 | ||
|
|
64453638bb | ||
|
|
5f1282a4ab | ||
|
|
645c3a67d8 | ||
|
|
88f72a654a | ||
|
|
87df102772 | ||
|
|
25ee8e551c | ||
|
|
99d48795b9 | ||
|
|
5681fa8f07 | ||
|
|
867d17b03d | ||
|
|
7751dd7535 | ||
|
|
16a885824d | ||
|
|
3934f7bf3a | ||
|
|
96cf6d59a3 | ||
|
|
d46a1a266d | ||
|
|
aaa1ebeed5 | ||
|
|
18ba50bc2d | ||
|
|
3df8840fee | ||
|
|
630b5df59a | ||
|
|
9db15aab92 | ||
|
|
f01e1ef0aa | ||
|
|
b5919ce92c | ||
|
|
f9b1fb5906 | ||
|
|
9238261e17 | ||
|
|
e8801ee22f | ||
|
|
8ec109d255 | ||
|
|
74c0429437 | ||
|
|
563588651c | ||
|
|
63614a477a | ||
|
|
2ea2bcab77 | ||
|
|
8d38016b0c | ||
|
|
d994d6bfad | ||
|
|
573f5de148 | ||
|
|
667f9c6fe4 | ||
|
|
f708292015 | ||
|
|
c50a7deb92 | ||
|
|
11fcffda4c | ||
|
|
f891d0f5be | ||
|
|
257b8b9b80 | ||
|
|
9a786e449b | ||
|
|
09dc4d663d | ||
|
|
12709ceaa3 | ||
|
|
67df162bcc | ||
|
|
a14980716d | ||
|
|
376d4e4fa0 | ||
|
|
e9cc359abe | ||
|
|
9b01972b41 | ||
|
|
3e65009ea9 | ||
|
|
a953601abd | ||
|
|
2744702f9b | ||
|
|
9c7d4381a1 | ||
|
|
5397c0d73a | ||
|
|
8ab31fe139 | ||
|
|
45649824ca | ||
|
|
914436f3d5 | ||
|
|
6f0c30ff84 | ||
|
|
943260fcd6 | ||
|
|
24aa580b63 | ||
|
|
8435d2f53d | ||
|
|
c51170ef6d | ||
|
|
9d491f5322 | ||
|
|
adb5579690 | ||
|
|
94662620e2 | ||
|
|
f1e378bff8 | ||
|
|
2e9db1f5c4 | ||
|
|
dec2d8d5b0 | ||
|
|
a439690bd7 | ||
|
|
5d7a2f92df | ||
|
|
8413101148 | ||
|
|
8fb66c351e | ||
|
|
16ad9c2ae6 | ||
|
|
4da719f43c | ||
|
|
2ad938ed44 | ||
|
|
969b15a297 | ||
|
|
c8449d8f8a | ||
|
|
6992a6fe6d | ||
|
|
2ece671bfd | ||
|
|
c13e5fcb92 | ||
|
|
bacecb4249 | ||
|
|
47755fb1e9 | ||
|
|
69d104bcb6 | ||
|
|
3783d1ce90 | ||
|
|
b043ac0f7f | ||
|
|
499bb3f4a2 | ||
|
|
d166f2da80 | ||
|
|
3032de1dc1 | ||
|
|
5341785aae | ||
|
|
289b1802fd | ||
|
|
0da3e73765 | ||
|
|
0a7055d475 | ||
|
|
a1ce14e70f | ||
|
|
2f2bcf0058 | ||
|
|
f929c38e98 | ||
|
|
9ffcd2d86a | ||
|
|
66a8bede12 | ||
|
|
c2891b9905 | ||
|
|
b8c272258e | ||
|
|
4cb9ac72b4 | ||
|
|
cf8bd92d4d | ||
|
|
90b2257347 | ||
|
|
914d90a2bc | ||
|
|
e567b2281d | ||
|
|
bb6567f84c | ||
|
|
617802653f | ||
|
|
26a485d43c | ||
|
|
456aa5a2b2 | ||
|
|
97173f495c | ||
|
|
24a8d60566 | ||
|
|
69cea6001f | ||
|
|
647b3ff0fe | ||
|
|
84365cde07 | ||
|
|
e91a1529e4 | ||
|
|
e8775ba2b4 | ||
|
|
4f8fec6494 | ||
|
|
57979faa9c | ||
|
|
994b829cb4 | ||
|
|
37fd438717 | ||
|
|
5e301dd599 | ||
|
|
3d5b3fb6ff | ||
|
|
6c5f98668e | ||
|
|
ef0eab0f40 | ||
|
|
dd9d53c83e | ||
|
|
89d856d147 | ||
|
|
156c6e2025 | ||
|
|
d21d7cef4c | ||
|
|
249981de96 | ||
|
|
8e173f1658 | ||
|
|
ced5eeacc2 | ||
|
|
4155e8a31f | ||
|
|
4ad76d8916 | ||
|
|
478eb48e93 | ||
|
|
d8ae079757 | ||
|
|
b0c2d24997 | ||
|
|
2e6cb2235c | ||
|
|
0009be595c | ||
|
|
7e7f9bc6ac | ||
|
|
ae63980152 | ||
|
|
a31501d99e | ||
|
|
6864a44b5a | ||
|
|
523af4fbca | ||
|
|
b9733d0d99 | ||
|
|
7ed8ed83e3 | ||
|
|
0e1fb74e1b | ||
|
|
1ce51bfbd6 | ||
|
|
cdb8361050 | ||
|
|
00c6f56cc8 | ||
|
|
b26506ad4a | ||
|
|
7bb5344942 | ||
|
|
ae5c4c7e13 | ||
|
|
507e8f8f12 | ||
|
|
5a15b2c036 | ||
|
|
977d86e7ca | ||
|
|
bd776c84bc | ||
|
|
68cd65567d | ||
|
|
ef07460792 | ||
|
|
f84a31871e | ||
|
|
b1ba11510b | ||
|
|
439f7978c3 | ||
|
|
85a724e289 | ||
|
|
df6239e0fc | ||
|
|
1be61df9c0 | ||
|
|
d1e1b9b38a | ||
|
|
975befd136 | ||
|
|
a708a81fa8 | ||
|
|
18d19fde0b | ||
|
|
1f0d113688 | ||
|
|
121abb450a | ||
|
|
1be388c587 | ||
|
|
994e2b6624 | ||
|
|
dbd0763f83 | ||
|
|
e1b2e00cf6 | ||
|
|
3c5e62d47e | ||
|
|
3be301fac9 | ||
|
|
9c3251b5f0 | ||
|
|
cb44607e96 | ||
|
|
1c8ef4e196 | ||
|
|
9e1fa7ef42 | ||
|
|
7c95e96ce8 | ||
|
|
21b88f2fe8 | ||
|
|
81d3161a5e | ||
|
|
8beb349e88 | ||
|
|
c105045dab | ||
|
|
b901a26c47 | ||
|
|
8ec550d6e0 | ||
|
|
6827256586 | ||
|
|
ef193b0f64 | ||
|
|
e782e2c0f3 | ||
|
|
ec2e94425e | ||
|
|
9f0adc16ad | ||
|
|
fa88d918b1 | ||
|
|
3800f00564 | ||
|
|
2ad0bd4036 | ||
|
|
70412fc0ba | ||
|
|
e4425e6a37 | ||
|
|
fdbab3e20c | ||
|
|
f2e399ccf3 | ||
|
|
07840f5397 | ||
|
|
c09e7e620f | ||
|
|
92e26495da | ||
|
|
061859cc4d | ||
|
|
45452e510c | ||
|
|
279ead2085 | ||
|
|
649f17fe47 | ||
|
|
e9e5bce10c | ||
|
|
e41ce1d6ec | ||
|
|
bc21a1b944 | ||
|
|
834077190f | ||
|
|
2a210607d3 | ||
|
|
1ff1639cef | ||
|
|
5eccfc2604 | ||
|
|
2469bc7e2e | ||
|
|
d540a084dd | ||
|
|
11eb29f520 | ||
|
|
e4d41fe313 | ||
|
|
b5e7414be2 | ||
|
|
83b0ef4e26 | ||
|
|
b682e48e12 | ||
|
|
e52ba87af1 | ||
|
|
6da0ae4d23 | ||
|
|
f8051a5698 | ||
|
|
2306d14b5d | ||
|
|
4035880003 | ||
|
|
9cfbd067d3 | ||
|
|
39fd70231f | ||
|
|
dc460f4d6a | ||
|
|
c31035d348 | ||
|
|
555184a4b7 | ||
|
|
e64e84ad7a | ||
|
|
1777270aa2 | ||
|
|
f5df567d09 | ||
|
|
899c2057b7 | ||
|
|
1b384c322a | ||
|
|
f4e84fbf84 | ||
|
|
d393380122 | ||
|
|
d0e4c95bbc | ||
|
|
34e1f1b6da | ||
|
|
6d432d19fe | ||
|
|
486efa9aba | ||
|
|
0ad9fcd8a0 | ||
|
|
c7f7912bca | ||
|
|
e776f88eec | ||
|
|
ee5d49a033 | ||
|
|
051903d30c | ||
|
|
91e1ae035e | ||
|
|
10a2ecd1d6 | ||
|
|
800eb4d86a | ||
|
|
e3a2e58623 | ||
|
|
ea073b5e87 | ||
|
|
619d01150f | ||
|
|
6540d2e073 | ||
|
|
69b694ff26 | ||
|
|
9e21765173 | ||
|
|
c0830f1c20 | ||
|
|
985f96662e | ||
|
|
e0229b799d | ||
|
|
3fbb56d5fb | ||
|
|
3a60c8bbed | ||
|
|
f411fb89e6 | ||
|
|
1b5cfa7331 | ||
|
|
39647a15ae | ||
|
|
ba2e43600e | ||
|
|
c998a55fe7 | ||
|
|
da8f93dca2 | ||
|
|
50daef9a52 | ||
|
|
45f12dd3c7 | ||
|
|
6aee535d7c | ||
|
|
2342709803 | ||
|
|
272be7cdae | ||
|
|
31fbfed0a6 | ||
|
|
b7486e5605 | ||
|
|
e8218c4b29 | ||
|
|
d3fed52254 | ||
|
|
1205eaaa22 | ||
|
|
69934a9598 | ||
|
|
f24773933c | ||
|
|
e17e080639 | ||
|
|
055e35b297 | ||
|
|
81604a9326 | ||
|
|
a0e9f9f218 | ||
|
|
0ab3e7a92a | ||
|
|
9512bb9587 | ||
|
|
da916d7b27 | ||
|
|
b370b6a4e4 | ||
|
|
1911168855 | ||
|
|
f98629b895 | ||
|
|
dc01b17260 | ||
|
|
ef61c0c3a4 | ||
|
|
664eae72d1 | ||
|
|
86658f310d | ||
|
|
a29f867908 | ||
|
|
28de2d6f75 | ||
|
|
37d98474d5 | ||
|
|
5116f02290 | ||
|
|
2233d7ca98 | ||
|
|
f58425dd3c | ||
|
|
39d19f2183 | ||
|
|
99c4c65f69 | ||
|
|
61901496ec | ||
|
|
0ab65f1ac5 | ||
|
|
debdc707e9 | ||
|
|
fcc918a146 | ||
|
|
b6bc0097b8 | ||
|
|
d556edae31 | ||
|
|
1fb2ea70c2 | ||
|
|
4cbcb4c3a2 | ||
|
|
d071df0dec | ||
|
|
f09f153014 | ||
|
|
1d8678c431 | ||
|
|
51c30980df | ||
|
|
cb20c9b1ea | ||
|
|
a7db2ebbe1 | ||
|
|
61721478f3 | ||
|
|
47fa928425 | ||
|
|
10a7accd00 | ||
|
|
527585ff9c | ||
|
|
2f15a40e97 | ||
|
|
ccef9a3e43 | ||
|
|
479dfd1710 | ||
|
|
34ad4bd32d | ||
|
|
6031801206 | ||
|
|
9cfe0db3c8 | ||
|
|
8ef2cfa364 | ||
|
|
12e69202f8 | ||
|
|
e4b2ae29bd | ||
|
|
ac4674fdb0 | ||
|
|
f86702e8ab | ||
|
|
9a84f8b763 | ||
|
|
6a32b9bf87 | ||
|
|
b152becbe0 | ||
|
|
c41aa12d1d | ||
|
|
8a81ee3b4f | ||
|
|
5e1836f3a2 | ||
|
|
9ea3be4dc1 | ||
|
|
bce47eb9a4 | ||
|
|
018bd8544c | ||
|
|
bfb9f2a00b | ||
|
|
3f8c91d77c | ||
|
|
ef5095cf53 | ||
|
|
5015071816 | ||
|
|
b110a80fbd | ||
|
|
c7a8f1143c | ||
|
|
dbe44c076e | ||
|
|
3246b49a45 | ||
|
|
c482d48fde | ||
|
|
0c7d46927e | ||
|
|
7d9f8b0d4c | ||
|
|
b8981b2675 | ||
|
|
f6935b5d27 | ||
|
|
6028db21ab | ||
|
|
c63fd974fb | ||
|
|
cdb86ed154 | ||
|
|
0f844311c9 | ||
|
|
91e8680fc5 | ||
|
|
6f2000f5e2 | ||
|
|
8d2359026c | ||
|
|
ee180c51cf | ||
|
|
b63312ff2e | ||
|
|
59f8a73676 | ||
|
|
affd4e7df3 | ||
|
|
38928c4c0e | ||
|
|
48af5116b3 | ||
|
|
a5112f317d | ||
|
|
eb5f6efb43 | ||
|
|
3ed47b05a5 | ||
|
|
7972d6a0c6 | ||
|
|
163cd72b7a | ||
|
|
bdea9e1333 | ||
|
|
2f8d66ef2b | ||
|
|
589b23b7e2 | ||
|
|
2e5131bb21 | ||
|
|
2ff5b4ce95 | ||
|
|
f8a478946e | ||
|
|
623f6c841b | ||
|
|
0b6f2f5b91 | ||
|
|
3445dc1f00 | ||
|
|
a11c2a0bd8 | ||
|
|
95da41aa15 | ||
|
|
27401f4975 | ||
|
|
d902a9f279 | ||
|
|
03847e6c41 | ||
|
|
a4f9602405 | ||
|
|
5f214ffa98 | ||
|
|
8ee3b535ef | ||
|
|
951372491c | ||
|
|
eeb79476de | ||
|
|
1b2d0e7a6f | ||
|
|
3208ad27ac | ||
|
|
cf87b76b0c | ||
|
|
5e71f0f0d7 | ||
|
|
be61e2e714 | ||
|
|
1e5596b594 | ||
|
|
744c277123 | ||
|
|
460bb69ade | ||
|
|
8dbe78a21a | ||
|
|
3959f82030 | ||
|
|
48ba13bc6c | ||
|
|
681082a3ad | ||
|
|
4013a90f33 | ||
|
|
316ef89541 | ||
|
|
a8dd81e986 | ||
|
|
4b257c3d01 | ||
|
|
491bc006b2 | ||
|
|
28ad0017e1 | ||
|
|
5849381dfb | ||
|
|
baa974a487 | ||
|
|
1a97ba1b46 | ||
|
|
1d68f4e279 | ||
|
|
a2b793c61b | ||
|
|
93d6fb8c60 | ||
|
|
c7f4bdafc0 | ||
|
|
867f80715e | ||
|
|
29e668e887 | ||
|
|
944f4f7c05 | ||
|
|
cd6544d32a | ||
|
|
b2f4bbf93b | ||
|
|
a99b4472a8 | ||
|
|
33f3e72dda | ||
|
|
e30510a688 | ||
|
|
974fe4d923 | ||
|
|
feb8aff46b | ||
|
|
eee9b50b70 | ||
|
|
9fb8bc8991 | ||
|
|
1c42caba76 | ||
|
|
9d59bfbe00 | ||
|
|
95dc06cca6 | ||
|
|
9ecbf86fa0 | ||
|
|
588fd1923f | ||
|
|
2824efd505 | ||
|
|
169c8d793a | ||
|
|
68f03dcc67 | ||
|
|
397f551e6d | ||
|
|
cbb5d34167 | ||
|
|
0cc9798c8f | ||
|
|
45a7ca62ae | ||
|
|
2eb125e90e | ||
|
|
264c618b11 | ||
|
|
d9cf8fcfe8 | ||
|
|
5e9c1098c0 | ||
|
|
d65bd7b7ea | ||
|
|
45a5ae1f23 | ||
|
|
3eda6db227 | ||
|
|
58f287f551 | ||
|
|
f62f64311d | ||
|
|
fbeaa57604 | ||
|
|
c1f5ead61d | ||
|
|
d7690c5fda | ||
|
|
45c35ceb2b | ||
|
|
bc481fa366 | ||
|
|
1b94fe3613 | ||
|
|
3204501174 | ||
|
|
f3dfc433c2 | ||
|
|
8213b1476f | ||
|
|
4e7dbf9ce5 | ||
|
|
ea2ff6aae3 | ||
|
|
50b6c5948d | ||
|
|
3acbd5a769 | ||
|
|
fddfb9e412 | ||
|
|
1325682d82 | ||
|
|
140a874917 | ||
|
|
b7c336a687 | ||
|
|
a38c0d6d15 | ||
|
|
75f40ccb06 | ||
|
|
4de847f84e | ||
|
|
33f1577dac | ||
|
|
ef3a83048c | ||
|
|
ae2ee8f006 | ||
|
|
6f6d86c700 | ||
|
|
d1b16e287c | ||
|
|
ee8a815e6b | ||
|
|
7bc2362e33 | ||
|
|
9a8389060c | ||
|
|
da3366859d | ||
|
|
200c0a8778 | ||
|
|
5cf9cd686c | ||
|
|
8e659baf25 | ||
|
|
2aa54ce22b | ||
|
|
eff334a1d0 | ||
|
|
b3bed7fb37 | ||
|
|
61b3822374 | ||
|
|
9fb04b5280 | ||
|
|
3341c5cf21 | ||
|
|
f1286f8e6b | ||
|
|
f2a99e83cd | ||
|
|
2f7b79764a | ||
|
|
ea18e06b08 | ||
|
|
a0193e8e42 | ||
|
|
2fcacbff23 | ||
|
|
a42288d056 | ||
|
|
7aa2a9e506 | ||
|
|
2fc0d83085 | ||
|
|
ca0d4226aa | ||
|
|
dff2e4ebc2 | ||
|
|
9c337bc621 | ||
|
|
5a1360678b | ||
|
|
33ee91a748 | ||
|
|
396895d077 | ||
|
|
8b04d48ffd | ||
|
|
2a76a0852f | ||
|
|
22d961de70 | ||
|
|
4650366f07 | ||
|
|
7b8ad64ba5 | ||
|
|
e64761b15e | ||
|
|
61273ff606 | ||
|
|
dfe17491f8 | ||
|
|
a8c7425e17 | ||
|
|
e5f0da75e2 | ||
|
|
6834e00be6 | ||
|
|
26375a3014 | ||
|
|
06c3f756b1 | ||
|
|
9c5bbfe96d | ||
|
|
e427f9ee38 | ||
|
|
e62e2bb131 | ||
|
|
bf17ed0917 | ||
|
|
058081b1f5 | ||
|
|
98722e10fc | ||
|
|
2781796d9c | ||
|
|
24d2261060 | ||
|
|
7d7c2104ea | ||
|
|
4ab502a691 | ||
|
|
9292d9255c | ||
|
|
2022d39339 | ||
|
|
e31dd4404e | ||
|
|
5dc29bd2c3 | ||
|
|
20c316bce4 | ||
|
|
8b475f45e9 | ||
|
|
a4318682f7 | ||
|
|
a14d8057ed | ||
|
|
d2f4bce6c0 | ||
|
|
b0a3207454 | ||
|
|
db3cdb288e | ||
|
|
8797cb78a9 | ||
|
|
7eb5cd1267 | ||
|
|
0b2aff61bb | ||
|
|
55f8b0a2f5 | ||
|
|
bb37300a48 | ||
|
|
0f12b37977 | ||
|
|
ad4cba70a0 | ||
|
|
dd7890c848 | ||
|
|
7f18739267 | ||
|
|
a1b478b3ac | ||
|
|
edf1f44668 | ||
|
|
60f780cc37 | ||
|
|
7d0cc7e26c | ||
|
|
864a254071 | ||
|
|
5995c6a2ac | ||
|
|
ed0cfc4f31 | ||
|
|
6db069881b | ||
|
|
ca4f69f557 | ||
|
|
37ccf87516 | ||
|
|
201c9fed77 | ||
|
|
3b5775573b | ||
|
|
6e22a0e4d9 | ||
|
|
ce5b4cd51e | ||
|
|
538236de8f | ||
|
|
1007bb83aa | ||
|
|
79955a5785 | ||
|
|
e60f9ca392 | ||
|
|
ae581694ac | ||
|
|
70fe463ef0 | ||
|
|
84858f5c19 | ||
|
|
a6ba5ec1c8 | ||
|
|
c2fe0d0120 | ||
|
|
b6ca03ce47 | ||
|
|
23f1b49e55 | ||
|
|
6e3ec97acf | ||
|
|
4a6afc5614 | ||
|
|
b557c17f76 | ||
|
|
c587536547 | ||
|
|
4c6394b307 | ||
|
|
534233388c | ||
|
|
43b31e88ba | ||
|
|
6197fe0121 | ||
|
|
1f6331c69d | ||
|
|
fd568d77c7 | ||
|
|
f32098abe4 | ||
|
|
b65d7daed8 | ||
|
|
9ea0c409e6 | ||
|
|
2ee62b10bc | ||
|
|
dbdd0a1f56 | ||
|
|
df8c59406b | ||
|
|
c5a2ffbcb9 | ||
|
|
e62bb299ff | ||
|
|
6ee8d9bd65 | ||
|
|
14a34f8c4b | ||
|
|
3b93fa80be | ||
|
|
57977bcef3 | ||
|
|
0d4841cbea | ||
|
|
f7d7d825b0 | ||
|
|
1d1408b98d | ||
|
|
b9eb0081cd | ||
|
|
287b1bce15 | ||
|
|
ec3d2e97e8 | ||
|
|
1ff329d9d6 | ||
|
|
703d71c064 | ||
|
|
a2a4c633f3 | ||
|
|
e6dd4f6e13 | ||
|
|
b327ea2023 | ||
|
|
b333dba875 | ||
|
|
02238b6412 | ||
|
|
bd62248841 | ||
|
|
dabbd7bd63 | ||
|
|
b5c7afcf75 | ||
|
|
f8f8da959a | ||
|
|
9970965718 | ||
|
|
a1d8b0e9b3 | ||
|
|
1e7cfc04af | ||
|
|
0f1bcfd63b | ||
|
|
f65c3940ae | ||
|
|
46de89e1a3 | ||
|
|
852526e10a | ||
|
|
91d6d0df84 | ||
|
|
cb129bd207 | ||
|
|
a6e9dc81aa | ||
|
|
5f7ac09a74 | ||
|
|
42775142f8 | ||
|
|
2525fc52b3 | ||
|
|
07dde62e70 | ||
|
|
cb458b7745 | ||
|
|
b2df199674 | ||
|
|
857c58c4b7 | ||
|
|
b82371f44b | ||
|
|
1c525968d1 | ||
|
|
5ec61e4649 | ||
|
|
184d0a99c0 | ||
|
|
232f56de62 | ||
|
|
66e33c7979 | ||
|
|
6420ab5535 | ||
|
|
ed3fe1cc6f | ||
|
|
cd1cfd7e8e | ||
|
|
31e23ebae2 | ||
|
|
fb65276daf | ||
|
|
bedd2d7e41 | ||
|
|
120111ceee | ||
|
|
e6390b8e41 | ||
|
|
d7fd9247a9 | ||
|
|
0dc155c4d3 | ||
|
|
0feb4c5439 | ||
|
|
f3588a8782 | ||
|
|
2145ac5e46 | ||
|
|
c39e6b9618 | ||
|
|
855cbc0aed | ||
|
|
00c366d7ea | ||
|
|
3c3a53a137 | ||
|
|
dd59054003 | ||
|
|
36f566a529 | ||
|
|
4d93a9fd38 | ||
|
|
d3df96a8de | ||
|
|
6c77702dcc | ||
|
|
86165750ff | ||
|
|
63b28aa39d | ||
|
|
279fd39677 | ||
|
|
c978281d1e | ||
|
|
311a44007c | ||
|
|
47401739ea | ||
|
|
11ba7cc8ce | ||
|
|
c3ad30ec87 | ||
|
|
a64a66dd62 | ||
|
|
dffe36761d | ||
|
|
0a186650bf | ||
|
|
6c77c9d372 | ||
|
|
4a4b9180d8 | ||
|
|
5d6db9a915 | ||
|
|
235282e335 | ||
|
|
6f582dcf24 | ||
|
|
9db8759317 | ||
|
|
136cc1d44d | ||
|
|
4c258ce08b | ||
|
|
3c04b0756f | ||
|
|
c0229ebb77 | ||
|
|
cfe7c0aa01 | ||
|
|
f874efb224 | ||
|
|
3da4642194 | ||
|
|
0aad056ca7 | ||
|
|
c5ceb40598 | ||
|
|
27a37e2013 | ||
|
|
10d1e81f10 | ||
|
|
56bbadb501 | ||
|
|
fa79aead9a | ||
|
|
24fec3e826 | ||
|
|
2524dca7bf | ||
|
|
56f17b8651 | ||
|
|
49623d2dad | ||
|
|
66479dc2e5 | ||
|
|
bbbec5a056 | ||
|
|
94b55efef3 | ||
|
|
fd38caa287 | ||
|
|
c61a652c90 | ||
|
|
e3e014bccc | ||
|
|
26590e244c | ||
|
|
39971ee919 | ||
|
|
2205090795 | ||
|
|
a277470363 | ||
|
|
19f2bbf52f | ||
|
|
dbb786c548 | ||
|
|
4fbe3bb070 | ||
|
|
9066ac44fe | ||
|
|
742144f401 | ||
|
|
c0b6a857f7 | ||
|
|
d6dee62c92 | ||
|
|
41017f10a3 | ||
|
|
ba50a5c329 | ||
|
|
4208bb457d | ||
|
|
15af6b1ad9 | ||
|
|
3921dc77a6 | ||
|
|
0094fd5c34 | ||
|
|
d58e401812 | ||
|
|
c79c94550f | ||
|
|
9b950f5192 | ||
|
|
2520fddbdf | ||
|
|
3f21966ec9 | ||
|
|
69502163bd | ||
|
|
893e0f8db6 | ||
|
|
1c8b52f630 | ||
|
|
6e4fb7a937 | ||
|
|
ab1939f56f | ||
|
|
15507df407 | ||
|
|
46ea28a4f8 | ||
|
|
c8458fd7c5 | ||
|
|
e681a7929c | ||
|
|
b2d37ccef6 | ||
|
|
9dd2c36de4 | ||
|
|
b92350fb55 | ||
|
|
6c0fc65eaf | ||
|
|
42ba2a68ce | ||
|
|
508d0459a7 | ||
|
|
dbae410cf4 | ||
|
|
ae51dc08bf | ||
|
|
672a3c7178 | ||
|
|
f8bc3411ad | ||
|
|
038168c417 | ||
|
|
73034c933e | ||
|
|
d3ceb9080c | ||
|
|
3893d8a876 | ||
|
|
05924a2868 | ||
|
|
021d08a9c4 | ||
|
|
5a71a22fb9 | ||
|
|
6064932e2e | ||
|
|
9de7034d0e | ||
|
|
96d5684a89 | ||
|
|
91962e2681 | ||
|
|
ee31f89049 | ||
|
|
370c3f28b8 | ||
|
|
66110a7d57 | ||
|
|
c419cbb46f | ||
|
|
a02d7989d5 | ||
|
|
7325847fa9 | ||
|
|
124495dd84 | ||
|
|
0c01f3a0fe | ||
|
|
0ea2d99910 | ||
|
|
6456f66b47 | ||
|
|
94eee6d069 | ||
|
|
6e5a2a77ab | ||
|
|
35b609dd8b | ||
|
|
0df99f8762 | ||
|
|
6781ecf159 | ||
|
|
a4b843eb2d | ||
|
|
302717e8a1 | ||
|
|
617647c5fd | ||
|
|
4b5d578c08 | ||
|
|
bfc55137ea | ||
|
|
e98e7e2751 | ||
|
|
b687de879c | ||
|
|
4048ad36a8 | ||
|
|
8c2f0e3b30 | ||
|
|
6cabbd2592 | ||
|
|
8d22754a06 | ||
|
|
be6d1b5e94 | ||
|
|
c84f1d7d33 | ||
|
|
49845d9398 | ||
|
|
895306f822 | ||
|
|
a729742757 | ||
|
|
6bc03ee763 | ||
|
|
75580dfade | ||
|
|
1f8699d9b4 | ||
|
|
fca5d55b43 | ||
|
|
659616a4eb | ||
|
|
3b4f7b4f5d | ||
|
|
62432ced90 | ||
|
|
27873b4457 | ||
|
|
1e7333eeb6 | ||
|
|
7cd620d30f | ||
|
|
7a180ac205 | ||
|
|
153ccda853 | ||
|
|
067e4f6d9a | ||
|
|
9d6ce609f9 | ||
|
|
9800b74a6d | ||
|
|
ef5b2a2492 | ||
|
|
60179a1cbb | ||
|
|
e0cea2d18d | ||
|
|
e29dfa8609 | ||
|
|
ef39bca52e | ||
|
|
5a3ea74a26 | ||
|
|
8869617890 | ||
|
|
62f970e486 | ||
|
|
f9a21dbfda | ||
|
|
86c6b4d8e3 | ||
|
|
7bfa81c592 | ||
|
|
d07e40c483 | ||
|
|
0e7e58f172 | ||
|
|
cbdfc95cc8 | ||
|
|
1642502a70 | ||
|
|
33ebd99068 | ||
|
|
9c17e95fc5 | ||
|
|
1533bc1e1f | ||
|
|
da3695dccc | ||
|
|
40c8f5f70e | ||
|
|
3ceee66e1b | ||
|
|
6b908b6f4e | ||
|
|
a74b081d44 | ||
|
|
bc8093c73b | ||
|
|
ca2712506b | ||
|
|
c871e8da5d | ||
|
|
722c27f1e2 | ||
|
|
e3fcf46566 | ||
|
|
1117371b31 | ||
|
|
addca54118 | ||
|
|
471d6e45eb | ||
|
|
7238205adb | ||
|
|
3db5d5bbf9 | ||
|
|
65970a2248 | ||
|
|
5d82f48c02 | ||
|
|
8e185bc300 | ||
|
|
a013908115 | ||
|
|
bdf6257640 | ||
|
|
1f50e335fa | ||
|
|
1375adfeab | ||
|
|
00cbdffa12 | ||
|
|
c5f012c85a | ||
|
|
656eae288e | ||
|
|
5898307715 | ||
|
|
9b0efdc8c8 | ||
|
|
87f9f17335 | ||
|
|
f101f6b7cb | ||
|
|
abf07b60f0 | ||
|
|
0b114f0755 | ||
|
|
3ee8f58fdf | ||
|
|
ff4da05267 | ||
|
|
17308a2730 | ||
|
|
7d9bce2153 | ||
|
|
2839f0ff5f | ||
|
|
2ec295a6f8 | ||
|
|
4bd7a7eee3 | ||
|
|
d0cbbe6141 | ||
|
|
9efa31ef9f | ||
|
|
8a777f6e78 | ||
|
|
ac13a2736b | ||
|
|
940577e105 | ||
|
|
c917470836 | ||
|
|
47a344f3a1 | ||
|
|
f744a29d9d | ||
|
|
3cd4cb741c | ||
|
|
1128104281 | ||
|
|
d6d685a483 | ||
|
|
2c6e6c2a6f | ||
|
|
c8e0de19b6 | ||
|
|
b2440a6d95 | ||
|
|
c36c3f0d64 | ||
|
|
0e7d284c83 | ||
|
|
cdd111df49 | ||
|
|
cccd0deb65 | ||
|
|
e014a84215 | ||
|
|
d549e26a9b | ||
|
|
08adfd87f7 | ||
|
|
65b0ec6615 | ||
|
|
cb646e48d0 | ||
|
|
fecce206a9 | ||
|
|
176ef411de | ||
|
|
2ac23c8be6 | ||
|
|
a373793029 | ||
|
|
3153b0c8fc | ||
|
|
89d008d1f3 | ||
|
|
6755ae2605 | ||
|
|
c18033ba85 | ||
|
|
cdc5388dc9 | ||
|
|
be4776d039 | ||
|
|
30111ea417 | ||
|
|
576c806e86 | ||
|
|
1c561eaf0d | ||
|
|
1da30032a0 | ||
|
|
d5bbb6ffd2 | ||
|
|
b4e5695bbd | ||
|
|
716ab0433f | ||
|
|
7d9ef97bda | ||
|
|
703b4354e0 | ||
|
|
ce0ca7ff90 | ||
|
|
54e87836f6 | ||
|
|
5f4aa6d2ba | ||
|
|
dc447a75c6 | ||
|
|
ce7e9e36dd | ||
|
|
8aca2e84dc | ||
|
|
f3e55ce330 | ||
|
|
20caeb5383 | ||
|
|
bc0d0751b9 | ||
|
|
5393b073fe | ||
|
|
d7b7370c82 | ||
|
|
5f65f67f1e | ||
|
|
f242418986 | ||
|
|
d3d9d9ebf2 | ||
|
|
8ceb57752b | ||
|
|
bd1af8c3d8 | ||
|
|
6af995026b | ||
|
|
19a30b0ce6 | ||
|
|
9a659a5d1d | ||
|
|
1cfd770b95 | ||
|
|
3fda97eed7 | ||
|
|
b657cff6ba | ||
|
|
e3fba79126 | ||
|
|
bb0068908d | ||
|
|
0748466ffc | ||
|
|
fe018fd58c | ||
|
|
10317a0f71 | ||
|
|
87d55834be | ||
|
|
d4cc806cd5 | ||
|
|
1a7e8c88a3 | ||
|
|
90a51160c4 | ||
|
|
50321a29b5 | ||
|
|
67d137cfd5 | ||
|
|
bb4d1773d3 | ||
|
|
a6c1192bfc | ||
|
|
d14d2fe588 | ||
|
|
f696331563 | ||
|
|
6b2b92a732 | ||
|
|
83ce9450f7 | ||
|
|
0b405c33c4 | ||
|
|
bf74cab7af | ||
|
|
d8adb4bdb0 | ||
|
|
33990badcd | ||
|
|
8061f15aec | ||
|
|
25f7c31911 | ||
|
|
bb98331ba4 | ||
|
|
07d139b3a8 | ||
|
|
f4ef8fd1bc | ||
|
|
ba836c2e36 | ||
|
|
a0ab356936 | ||
|
|
734a83c657 | ||
|
|
b42f4012d1 | ||
|
|
8501312292 | ||
|
|
3faed2edc1 | ||
|
|
bc70619b17 | ||
|
|
bef15264b7 | ||
|
|
6d26915c69 | ||
|
|
fa2e6ada26 | ||
|
|
a6880c452f | ||
|
|
4bccb0d2a1 | ||
|
|
103639455c | ||
|
|
549abd9c7e | ||
|
|
f1aba5511f | ||
|
|
21d05a8b4d | ||
|
|
cb6c869c2f | ||
|
|
640e499964 | ||
|
|
b3b4f7468d | ||
|
|
ad9621ebe5 | ||
|
|
e370d523ec | ||
|
|
61a41bb8fc | ||
|
|
2da6d3c223 | ||
|
|
816efa02d1 | ||
|
|
bd1b1a9ff9 | ||
|
|
1d23f7f900 | ||
|
|
39843a73de | ||
|
|
aec425d1f6 | ||
|
|
855ed2b4e4 | ||
|
|
2dc40fe16e | ||
|
|
bf8376ddcb | ||
|
|
8f696193f0 | ||
|
|
70edb2492a | ||
|
|
e35d4beb95 | ||
|
|
919b431a24 | ||
|
|
7f59a8ea0c | ||
|
|
1ac3f0da63 | ||
|
|
12e679c14d | ||
|
|
28ef94c3fa | ||
|
|
27df4cca6c | ||
|
|
a8413249c2 | ||
|
|
f2dacb2570 | ||
|
|
5aaf81f2c9 | ||
|
|
b86cd325fe | ||
|
|
74b7dabf2d | ||
|
|
1ce4c2092a | ||
|
|
875e05ff38 | ||
|
|
fe0e49db4b | ||
|
|
ad86e68c1e | ||
|
|
e7985c970b | ||
|
|
cfac537f51 | ||
|
|
d6e76969cc | ||
|
|
77dca8272c | ||
|
|
3b8ee196be | ||
|
|
4935043f4a | ||
|
|
f5d74e07d5 | ||
|
|
0a724a5473 | ||
|
|
cba8333a13 | ||
|
|
fcbc399809 | ||
|
|
f6eb9e79d5 | ||
|
|
ab3717af76 | ||
|
|
6cd69b413c | ||
|
|
de56a0d021 | ||
|
|
99fdd3e358 | ||
|
|
9a3107aa66 | ||
|
|
d31e01b877 | ||
|
|
f8c8900297 | ||
|
|
ed9cf994c2 | ||
|
|
d4a4938fce | ||
|
|
f7f0138cff | ||
|
|
753ffdaffd | ||
|
|
40aba3d785 | ||
|
|
64f157a036 | ||
|
|
0eddd287c5 | ||
|
|
f32b50cb80 | ||
|
|
a58a566ae8 | ||
|
|
2f1d40e014 | ||
|
|
14ee6178f9 | ||
|
|
753fe8279b | ||
|
|
cc264f415e | ||
|
|
dae90abb34 | ||
|
|
60f692c7bb | ||
|
|
7094d6d61e | ||
|
|
08fc73aa20 | ||
|
|
c14e41f431 | ||
|
|
f1f4d80f24 | ||
|
|
e746b92e0e | ||
|
|
7d2563eb1f | ||
|
|
084b3287ab | ||
|
|
4105429639 | ||
|
|
8c93b484c4 | ||
|
|
3b38de63ea | ||
|
|
eff1d1f14e | ||
|
|
fcb60d472e | ||
|
|
f2a2f2cca5 | ||
|
|
8c7f0669c6 | ||
|
|
d36c7c3de7 | ||
|
|
79efb0e607 | ||
|
|
9bc26e93a4 | ||
|
|
6c3e2021df | ||
|
|
07255a29b4 | ||
|
|
144bb3492a | ||
|
|
6f4dd7b057 | ||
|
|
27f3285d17 | ||
|
|
9a87e62e0e | ||
|
|
9044a9157f | ||
|
|
799ae894a8 | ||
|
|
bff1e1ff6c | ||
|
|
cc2437614b | ||
|
|
0700886d1a | ||
|
|
cd0e321668 | ||
|
|
94a82ab7dc | ||
|
|
b6e4a7771a | ||
|
|
13859388c1 | ||
|
|
2f4c5f949b | ||
|
|
36e8157268 | ||
|
|
2d88f47795 | ||
|
|
5acfe5da68 | ||
|
|
5f9e4ae136 | ||
|
|
a9b0f92afe | ||
|
|
07b2728380 | ||
|
|
a5e66ce6ba | ||
|
|
eae9726bec | ||
|
|
c425afe50e | ||
|
|
bcde57bff8 | ||
|
|
dfd7ef1fce | ||
|
|
fdb250d86c | ||
|
|
8de56cfc10 | ||
|
|
7ea25cd360 | ||
|
|
41fc44b27c | ||
|
|
a55fbd2be7 | ||
|
|
28d6910e56 | ||
|
|
edfc54b2eb | ||
|
|
6ceafabd78 | ||
|
|
48972c7570 | ||
|
|
bf3ead3359 | ||
|
|
b4f8d52fb1 | ||
|
|
143be49c66 | ||
|
|
a9f19a16ee | ||
|
|
d53a8c0823 | ||
|
|
6e5c541a00 | ||
|
|
2cd127921a | ||
|
|
fa9b9105a8 | ||
|
|
4fb4838bde | ||
|
|
3a487e54a2 | ||
|
|
36da82aa8d | ||
|
|
5205354cb7 | ||
|
|
3498234448 | ||
|
|
c13ebacce1 | ||
|
|
ad49942201 | ||
|
|
82770faad7 | ||
|
|
a2f9fdf339 | ||
|
|
a2decdaaa3 | ||
|
|
72a1b7ae3f | ||
|
|
2753dd0c5e | ||
|
|
118c49ecaa | ||
|
|
0d9b3bea10 | ||
|
|
23afdec767 | ||
|
|
6e941af9b2 | ||
|
|
2ff61786bc | ||
|
|
9791c6b21b | ||
|
|
a183043d5d | ||
|
|
0589379de5 | ||
|
|
ee7e59fe68 | ||
|
|
b489519930 | ||
|
|
4395217031 | ||
|
|
c8ad9c4daa | ||
|
|
c050eb4100 | ||
|
|
c8a53c564a | ||
|
|
c316d5b0b9 | ||
|
|
e88fc33eef | ||
|
|
74f1f08ab5 | ||
|
|
aa51bb6cb9 | ||
|
|
8deb462471 | ||
|
|
46dc9322a2 | ||
|
|
daf8143d01 | ||
|
|
54dfe045b2 | ||
|
|
29e659cf4c |
107
.coveragerc
107
.coveragerc
@@ -42,6 +42,7 @@ omit =
|
||||
|
||||
homeassistant/components/asterisk_mbox.py
|
||||
homeassistant/components/*/asterisk_mbox.py
|
||||
homeassistant/components/*/asterisk_cdr.py
|
||||
|
||||
homeassistant/components/august.py
|
||||
homeassistant/components/*/august.py
|
||||
@@ -61,6 +62,11 @@ omit =
|
||||
homeassistant/components/coinbase.py
|
||||
homeassistant/components/sensor/coinbase.py
|
||||
|
||||
homeassistant/components/cast/*
|
||||
homeassistant/components/*/cast.py
|
||||
|
||||
homeassistant/components/cloudflare.py
|
||||
|
||||
homeassistant/components/comfoconnect.py
|
||||
homeassistant/components/*/comfoconnect.py
|
||||
|
||||
@@ -87,6 +93,9 @@ omit =
|
||||
homeassistant/components/ecobee.py
|
||||
homeassistant/components/*/ecobee.py
|
||||
|
||||
homeassistant/components/edp_redy.py
|
||||
homeassistant/components/*/edp_redy.py
|
||||
|
||||
homeassistant/components/egardia.py
|
||||
homeassistant/components/*/egardia.py
|
||||
|
||||
@@ -97,7 +106,10 @@ omit =
|
||||
homeassistant/components/*/envisalink.py
|
||||
|
||||
homeassistant/components/fritzbox.py
|
||||
homeassistant/components/*/fritzbox.py
|
||||
homeassistant/components/switch/fritzbox.py
|
||||
|
||||
homeassistant/components/ecovacs.py
|
||||
homeassistant/components/*/ecovacs.py
|
||||
|
||||
homeassistant/components/eufy.py
|
||||
homeassistant/components/*/eufy.py
|
||||
@@ -108,6 +120,16 @@ omit =
|
||||
homeassistant/components/google.py
|
||||
homeassistant/components/*/google.py
|
||||
|
||||
homeassistant/components/habitica/*
|
||||
homeassistant/components/*/habitica.py
|
||||
|
||||
homeassistant/components/hangouts/__init__.py
|
||||
homeassistant/components/hangouts/const.py
|
||||
homeassistant/components/hangouts/hangouts_bot.py
|
||||
homeassistant/components/hangouts/hangups_utils.py
|
||||
homeassistant/components/hangouts/intents.py
|
||||
homeassistant/components/*/hangouts.py
|
||||
|
||||
homeassistant/components/hdmi_cec.py
|
||||
homeassistant/components/*/hdmi_cec.py
|
||||
|
||||
@@ -123,14 +145,21 @@ omit =
|
||||
homeassistant/components/homematicip_cloud.py
|
||||
homeassistant/components/*/homematicip_cloud.py
|
||||
|
||||
homeassistant/components/huawei_lte.py
|
||||
homeassistant/components/*/huawei_lte.py
|
||||
|
||||
homeassistant/components/hydrawise.py
|
||||
homeassistant/components/*/hydrawise.py
|
||||
|
||||
homeassistant/components/ihc/*
|
||||
homeassistant/components/*/ihc.py
|
||||
|
||||
homeassistant/components/insteon_local.py
|
||||
homeassistant/components/*/insteon_local.py
|
||||
homeassistant/components/insteon/*
|
||||
homeassistant/components/*/insteon.py
|
||||
|
||||
homeassistant/components/insteon_plm/*
|
||||
homeassistant/components/*/insteon_plm.py
|
||||
homeassistant/components/insteon_local.py
|
||||
|
||||
homeassistant/components/insteon_plm.py
|
||||
|
||||
homeassistant/components/ios.py
|
||||
homeassistant/components/*/ios.py
|
||||
@@ -162,6 +191,9 @@ omit =
|
||||
homeassistant/components/linode.py
|
||||
homeassistant/components/*/linode.py
|
||||
|
||||
homeassistant/components/logi_circle.py
|
||||
homeassistant/components/*/logi_circle.py
|
||||
|
||||
homeassistant/components/lutron.py
|
||||
homeassistant/components/*/lutron.py
|
||||
|
||||
@@ -186,24 +218,30 @@ omit =
|
||||
homeassistant/components/mychevy.py
|
||||
homeassistant/components/*/mychevy.py
|
||||
|
||||
homeassistant/components/mysensors.py
|
||||
homeassistant/components/mysensors/*
|
||||
homeassistant/components/*/mysensors.py
|
||||
|
||||
homeassistant/components/neato.py
|
||||
homeassistant/components/*/neato.py
|
||||
|
||||
homeassistant/components/nest.py
|
||||
homeassistant/components/nest/__init__.py
|
||||
homeassistant/components/*/nest.py
|
||||
|
||||
homeassistant/components/netatmo.py
|
||||
homeassistant/components/*/netatmo.py
|
||||
|
||||
homeassistant/components/netgear_lte.py
|
||||
homeassistant/components/*/netgear_lte.py
|
||||
|
||||
homeassistant/components/octoprint.py
|
||||
homeassistant/components/*/octoprint.py
|
||||
|
||||
homeassistant/components/opencv.py
|
||||
homeassistant/components/*/opencv.py
|
||||
|
||||
homeassistant/components/openuv/__init__.py
|
||||
homeassistant/components/*/openuv.py
|
||||
|
||||
homeassistant/components/pilight.py
|
||||
homeassistant/components/*/pilight.py
|
||||
|
||||
@@ -216,7 +254,7 @@ omit =
|
||||
homeassistant/components/raincloud.py
|
||||
homeassistant/components/*/raincloud.py
|
||||
|
||||
homeassistant/components/rainmachine.py
|
||||
homeassistant/components/rainmachine/*
|
||||
homeassistant/components/*/rainmachine.py
|
||||
|
||||
homeassistant/components/raspihats.py
|
||||
@@ -240,12 +278,18 @@ omit =
|
||||
homeassistant/components/scsgate.py
|
||||
homeassistant/components/*/scsgate.py
|
||||
|
||||
homeassistant/components/sisyphus.py
|
||||
homeassistant/components/*/sisyphus.py
|
||||
|
||||
homeassistant/components/skybell.py
|
||||
homeassistant/components/*/skybell.py
|
||||
|
||||
homeassistant/components/smappee.py
|
||||
homeassistant/components/*/smappee.py
|
||||
|
||||
homeassistant/components/sonos/__init__.py
|
||||
homeassistant/components/*/sonos.py
|
||||
|
||||
homeassistant/components/tado.py
|
||||
homeassistant/components/*/tado.py
|
||||
|
||||
@@ -308,6 +352,9 @@ omit =
|
||||
homeassistant/components/wink/*
|
||||
homeassistant/components/*/wink.py
|
||||
|
||||
homeassistant/components/wirelesstag.py
|
||||
homeassistant/components/*/wirelesstag.py
|
||||
|
||||
homeassistant/components/xiaomi_aqara.py
|
||||
homeassistant/components/*/xiaomi_aqara.py
|
||||
|
||||
@@ -326,6 +373,12 @@ omit =
|
||||
homeassistant/components/zoneminder.py
|
||||
homeassistant/components/*/zoneminder.py
|
||||
|
||||
homeassistant/components/tuya.py
|
||||
homeassistant/components/*/tuya.py
|
||||
|
||||
homeassistant/components/spider.py
|
||||
homeassistant/components/*/spider.py
|
||||
|
||||
homeassistant/components/alarm_control_panel/alarmdotcom.py
|
||||
homeassistant/components/alarm_control_panel/canary.py
|
||||
homeassistant/components/alarm_control_panel/concord232.py
|
||||
@@ -335,6 +388,7 @@ omit =
|
||||
homeassistant/components/alarm_control_panel/nx584.py
|
||||
homeassistant/components/alarm_control_panel/simplisafe.py
|
||||
homeassistant/components/alarm_control_panel/totalconnect.py
|
||||
homeassistant/components/alarm_control_panel/yale_smart_alarm.py
|
||||
homeassistant/components/apiai.py
|
||||
homeassistant/components/binary_sensor/arest.py
|
||||
homeassistant/components/binary_sensor/concord232.py
|
||||
@@ -345,6 +399,7 @@ omit =
|
||||
homeassistant/components/binary_sensor/ping.py
|
||||
homeassistant/components/binary_sensor/rest.py
|
||||
homeassistant/components/binary_sensor/tapsaff.py
|
||||
homeassistant/components/binary_sensor/uptimerobot.py
|
||||
homeassistant/components/browser.py
|
||||
homeassistant/components/calendar/caldav.py
|
||||
homeassistant/components/calendar/todoist.py
|
||||
@@ -360,6 +415,7 @@ omit =
|
||||
homeassistant/components/camera/rpi_camera.py
|
||||
homeassistant/components/camera/synology.py
|
||||
homeassistant/components/camera/xeoma.py
|
||||
homeassistant/components/camera/xiaomi.py
|
||||
homeassistant/components/camera/yi.py
|
||||
homeassistant/components/climate/econet.py
|
||||
homeassistant/components/climate/ephember.py
|
||||
@@ -370,11 +426,15 @@ omit =
|
||||
homeassistant/components/climate/honeywell.py
|
||||
homeassistant/components/climate/knx.py
|
||||
homeassistant/components/climate/oem.py
|
||||
homeassistant/components/climate/opentherm_gw.py
|
||||
homeassistant/components/climate/proliphix.py
|
||||
homeassistant/components/climate/radiotherm.py
|
||||
homeassistant/components/climate/sensibo.py
|
||||
homeassistant/components/climate/touchline.py
|
||||
homeassistant/components/climate/venstar.py
|
||||
homeassistant/components/climate/zhong_hong.py
|
||||
homeassistant/components/cover/aladdin_connect.py
|
||||
homeassistant/components/cover/brunt.py
|
||||
homeassistant/components/cover/garadget.py
|
||||
homeassistant/components/cover/gogogate2.py
|
||||
homeassistant/components/cover/homematic.py
|
||||
@@ -382,6 +442,7 @@ omit =
|
||||
homeassistant/components/cover/myq.py
|
||||
homeassistant/components/cover/opengarage.py
|
||||
homeassistant/components/cover/rpi_gpio.py
|
||||
homeassistant/components/cover/ryobi_gdo.py
|
||||
homeassistant/components/cover/scsgate.py
|
||||
homeassistant/components/device_tracker/actiontec.py
|
||||
homeassistant/components/device_tracker/aruba.py
|
||||
@@ -393,6 +454,7 @@ omit =
|
||||
homeassistant/components/device_tracker/bt_home_hub_5.py
|
||||
homeassistant/components/device_tracker/cisco_ios.py
|
||||
homeassistant/components/device_tracker/ddwrt.py
|
||||
homeassistant/components/device_tracker/freebox.py
|
||||
homeassistant/components/device_tracker/fritz.py
|
||||
homeassistant/components/device_tracker/google_maps.py
|
||||
homeassistant/components/device_tracker/gpslogger.py
|
||||
@@ -407,6 +469,7 @@ omit =
|
||||
homeassistant/components/device_tracker/netgear.py
|
||||
homeassistant/components/device_tracker/nmap_tracker.py
|
||||
homeassistant/components/device_tracker/ping.py
|
||||
homeassistant/components/device_tracker/ritassist.py
|
||||
homeassistant/components/device_tracker/sky_hub.py
|
||||
homeassistant/components/device_tracker/snmp.py
|
||||
homeassistant/components/device_tracker/swisscom.py
|
||||
@@ -436,6 +499,7 @@ omit =
|
||||
homeassistant/components/light/decora_wifi.py
|
||||
homeassistant/components/light/decora.py
|
||||
homeassistant/components/light/flux_led.py
|
||||
homeassistant/components/light/futurenow.py
|
||||
homeassistant/components/light/greenwave.py
|
||||
homeassistant/components/light/hue.py
|
||||
homeassistant/components/light/hyperion.py
|
||||
@@ -443,6 +507,7 @@ omit =
|
||||
homeassistant/components/light/lifx_legacy.py
|
||||
homeassistant/components/light/lifx.py
|
||||
homeassistant/components/light/limitlessled.py
|
||||
homeassistant/components/light/lw12wifi.py
|
||||
homeassistant/components/light/mystrom.py
|
||||
homeassistant/components/light/nanoleaf_aurora.py
|
||||
homeassistant/components/light/osramlightify.py
|
||||
@@ -457,6 +522,7 @@ omit =
|
||||
homeassistant/components/light/yeelightsunflower.py
|
||||
homeassistant/components/light/zengge.py
|
||||
homeassistant/components/lirc.py
|
||||
homeassistant/components/lock/kiwi.py
|
||||
homeassistant/components/lock/lockitron.py
|
||||
homeassistant/components/lock/nello.py
|
||||
homeassistant/components/lock/nuki.py
|
||||
@@ -467,19 +533,21 @@ omit =
|
||||
homeassistant/components/media_player/aquostv.py
|
||||
homeassistant/components/media_player/bluesound.py
|
||||
homeassistant/components/media_player/braviatv.py
|
||||
homeassistant/components/media_player/cast.py
|
||||
homeassistant/components/media_player/channels.py
|
||||
homeassistant/components/media_player/clementine.py
|
||||
homeassistant/components/media_player/cmus.py
|
||||
homeassistant/components/media_player/denon.py
|
||||
homeassistant/components/media_player/denonavr.py
|
||||
homeassistant/components/media_player/directv.py
|
||||
homeassistant/components/media_player/dlna_dmr.py
|
||||
homeassistant/components/media_player/dunehd.py
|
||||
homeassistant/components/media_player/emby.py
|
||||
homeassistant/components/media_player/epson.py
|
||||
homeassistant/components/media_player/firetv.py
|
||||
homeassistant/components/media_player/frontier_silicon.py
|
||||
homeassistant/components/media_player/gpmdp.py
|
||||
homeassistant/components/media_player/gstreamer.py
|
||||
homeassistant/components/media_player/horizon.py
|
||||
homeassistant/components/media_player/itunes.py
|
||||
homeassistant/components/media_player/kodi.py
|
||||
homeassistant/components/media_player/lg_netcast.py
|
||||
@@ -495,13 +563,13 @@ omit =
|
||||
homeassistant/components/media_player/pandora.py
|
||||
homeassistant/components/media_player/philips_js.py
|
||||
homeassistant/components/media_player/pioneer.py
|
||||
homeassistant/components/media_player/pjlink.py
|
||||
homeassistant/components/media_player/plex.py
|
||||
homeassistant/components/media_player/roku.py
|
||||
homeassistant/components/media_player/russound_rio.py
|
||||
homeassistant/components/media_player/russound_rnet.py
|
||||
homeassistant/components/media_player/snapcast.py
|
||||
homeassistant/components/media_player/songpal.py
|
||||
homeassistant/components/media_player/sonos.py
|
||||
homeassistant/components/media_player/spotify.py
|
||||
homeassistant/components/media_player/squeezebox.py
|
||||
homeassistant/components/media_player/ue_smart_radio.py
|
||||
@@ -518,9 +586,10 @@ omit =
|
||||
homeassistant/components/notify/aws_sqs.py
|
||||
homeassistant/components/notify/ciscospark.py
|
||||
homeassistant/components/notify/clickatell.py
|
||||
homeassistant/components/notify/clicksend_tts.py
|
||||
homeassistant/components/notify/clicksend.py
|
||||
homeassistant/components/notify/clicksend_tts.py
|
||||
homeassistant/components/notify/discord.py
|
||||
homeassistant/components/notify/flock.py
|
||||
homeassistant/components/notify/free_mobile.py
|
||||
homeassistant/components/notify/gntp.py
|
||||
homeassistant/components/notify/group.py
|
||||
@@ -533,7 +602,6 @@ omit =
|
||||
homeassistant/components/notify/message_bird.py
|
||||
homeassistant/components/notify/mycroft.py
|
||||
homeassistant/components/notify/nfandroidtv.py
|
||||
homeassistant/components/notify/nma.py
|
||||
homeassistant/components/notify/prowl.py
|
||||
homeassistant/components/notify/pushbullet.py
|
||||
homeassistant/components/notify/pushetta.py
|
||||
@@ -542,6 +610,7 @@ omit =
|
||||
homeassistant/components/notify/rest.py
|
||||
homeassistant/components/notify/rocketchat.py
|
||||
homeassistant/components/notify/sendgrid.py
|
||||
homeassistant/components/notify/simplepush.py
|
||||
homeassistant/components/notify/slack.py
|
||||
homeassistant/components/notify/smtp.py
|
||||
homeassistant/components/notify/stride.py
|
||||
@@ -589,11 +658,13 @@ omit =
|
||||
homeassistant/components/sensor/domain_expiry.py
|
||||
homeassistant/components/sensor/dte_energy_bridge.py
|
||||
homeassistant/components/sensor/dublin_bus_transport.py
|
||||
homeassistant/components/sensor/duke_energy.py
|
||||
homeassistant/components/sensor/dwd_weather_warnings.py
|
||||
homeassistant/components/sensor/ebox.py
|
||||
homeassistant/components/sensor/eddystone_temperature.py
|
||||
homeassistant/components/sensor/eliqonline.py
|
||||
homeassistant/components/sensor/emoncms.py
|
||||
homeassistant/components/sensor/enphase_envoy.py
|
||||
homeassistant/components/sensor/envirophat.py
|
||||
homeassistant/components/sensor/etherscan.py
|
||||
homeassistant/components/sensor/fastdotcom.py
|
||||
@@ -619,14 +690,17 @@ omit =
|
||||
homeassistant/components/sensor/imap_email_content.py
|
||||
homeassistant/components/sensor/imap.py
|
||||
homeassistant/components/sensor/influxdb.py
|
||||
homeassistant/components/sensor/iperf3.py
|
||||
homeassistant/components/sensor/irish_rail_transport.py
|
||||
homeassistant/components/sensor/kwb.py
|
||||
homeassistant/components/sensor/lacrosse.py
|
||||
homeassistant/components/sensor/lastfm.py
|
||||
homeassistant/components/sensor/linky.py
|
||||
homeassistant/components/sensor/linux_battery.py
|
||||
homeassistant/components/sensor/loopenergy.py
|
||||
homeassistant/components/sensor/luftdaten.py
|
||||
homeassistant/components/sensor/lyft.py
|
||||
homeassistant/components/sensor/magicseaweed.py
|
||||
homeassistant/components/sensor/metoffice.py
|
||||
homeassistant/components/sensor/miflora.py
|
||||
homeassistant/components/sensor/mitemp_bt.py
|
||||
@@ -636,7 +710,10 @@ omit =
|
||||
homeassistant/components/sensor/mvglive.py
|
||||
homeassistant/components/sensor/nederlandse_spoorwegen.py
|
||||
homeassistant/components/sensor/netdata.py
|
||||
homeassistant/components/sensor/netdata_public.py
|
||||
homeassistant/components/sensor/neurio_energy.py
|
||||
homeassistant/components/sensor/noaa_tides.py
|
||||
homeassistant/components/sensor/nsw_fuel_station.py
|
||||
homeassistant/components/sensor/nut.py
|
||||
homeassistant/components/sensor/nzbget.py
|
||||
homeassistant/components/sensor/ohmconnect.py
|
||||
@@ -675,6 +752,7 @@ omit =
|
||||
homeassistant/components/sensor/sonarr.py
|
||||
homeassistant/components/sensor/speedtest.py
|
||||
homeassistant/components/sensor/spotcrime.py
|
||||
homeassistant/components/sensor/starlingbank.py
|
||||
homeassistant/components/sensor/steam_online.py
|
||||
homeassistant/components/sensor/supervisord.py
|
||||
homeassistant/components/sensor/swiss_hydrological_data.py
|
||||
@@ -699,6 +777,7 @@ omit =
|
||||
homeassistant/components/sensor/uscis.py
|
||||
homeassistant/components/sensor/vasttrafik.py
|
||||
homeassistant/components/sensor/viaggiatreno.py
|
||||
homeassistant/components/sensor/volkszaehler.py
|
||||
homeassistant/components/sensor/waqi.py
|
||||
homeassistant/components/sensor/waze_travel_time.py
|
||||
homeassistant/components/sensor/whois.py
|
||||
@@ -729,6 +808,8 @@ omit =
|
||||
homeassistant/components/switch/rest.py
|
||||
homeassistant/components/switch/rpi_rf.py
|
||||
homeassistant/components/switch/snmp.py
|
||||
homeassistant/components/switch/switchbot.py
|
||||
homeassistant/components/switch/switchmate.py
|
||||
homeassistant/components/switch/telnet.py
|
||||
homeassistant/components/switch/tplink.py
|
||||
homeassistant/components/switch/transmission.py
|
||||
@@ -741,9 +822,11 @@ omit =
|
||||
homeassistant/components/tts/picotts.py
|
||||
homeassistant/components/vacuum/mqtt.py
|
||||
homeassistant/components/vacuum/roomba.py
|
||||
homeassistant/components/watson_iot.py
|
||||
homeassistant/components/weather/bom.py
|
||||
homeassistant/components/weather/buienradar.py
|
||||
homeassistant/components/weather/darksky.py
|
||||
homeassistant/components/weather/met.py
|
||||
homeassistant/components/weather/metoffice.py
|
||||
homeassistant/components/weather/openweathermap.py
|
||||
homeassistant/components/weather/zamg.py
|
||||
|
||||
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -3,7 +3,7 @@
|
||||
|
||||
**Related issue (if applicable):** fixes #<home-assistant issue number goes here>
|
||||
|
||||
**Pull request in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io) with documentation (if applicable):** home-assistant/home-assistant.github.io#<home-assistant.github.io PR number goes here>
|
||||
**Pull request in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) with documentation (if applicable):** home-assistant/home-assistant.io#<home-assistant.io PR number goes here>
|
||||
|
||||
## Example entry for `configuration.yaml` (if applicable):
|
||||
```yaml
|
||||
@@ -15,12 +15,12 @@
|
||||
- [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass**
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
- [ ] Documentation added/updated in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io)
|
||||
- [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io)
|
||||
|
||||
If the code communicates with devices, web services, or third-party tools:
|
||||
- [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]).
|
||||
- [ ] New dependencies are only imported inside functions that use them ([example][ex-import]).
|
||||
- [ ] New dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`.
|
||||
- [ ] New or updated dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`.
|
||||
- [ ] New files were added to `.coveragerc`.
|
||||
|
||||
If the code does not interact with devices:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -107,3 +107,6 @@ desktop.ini
|
||||
|
||||
# Secrets
|
||||
.lokalise_token
|
||||
|
||||
# monkeytype
|
||||
monkeytype.sqlite3
|
||||
|
||||
2
.isort.cfg
Normal file
2
.isort.cfg
Normal file
@@ -0,0 +1,2 @@
|
||||
[settings]
|
||||
multi_line_output=4
|
||||
20
.travis.yml
20
.travis.yml
@@ -13,14 +13,21 @@ matrix:
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=typing
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=py35
|
||||
env: TOXENV=cov
|
||||
after_success: coveralls
|
||||
- python: "3.6"
|
||||
env: TOXENV=py36
|
||||
# - python: "3.6-dev"
|
||||
# env: TOXENV=py36
|
||||
# allow_failures:
|
||||
# - python: "3.5"
|
||||
# env: TOXENV=typing
|
||||
- python: "3.7"
|
||||
env: TOXENV=py37
|
||||
dist: xenial
|
||||
- python: "3.8-dev"
|
||||
env: TOXENV=py38
|
||||
dist: xenial
|
||||
if: branch = dev AND type = push
|
||||
allow_failures:
|
||||
- python: "3.8-dev"
|
||||
env: TOXENV=py38
|
||||
dist: xenial
|
||||
|
||||
cache:
|
||||
directories:
|
||||
@@ -39,4 +46,3 @@ deploy:
|
||||
on:
|
||||
branch: dev
|
||||
condition: $TOXENV = lint
|
||||
after_success: coveralls
|
||||
|
||||
18
CODEOWNERS
18
CODEOWNERS
@@ -52,6 +52,8 @@ homeassistant/components/cover/template.py @PhracturedBlue
|
||||
homeassistant/components/device_tracker/automatic.py @armills
|
||||
homeassistant/components/device_tracker/tile.py @bachya
|
||||
homeassistant/components/history_graph.py @andrey-git
|
||||
homeassistant/components/light/lifx.py @amelchio
|
||||
homeassistant/components/light/lifx_legacy.py @amelchio
|
||||
homeassistant/components/light/tplink.py @rytilahti
|
||||
homeassistant/components/light/yeelight.py @rytilahti
|
||||
homeassistant/components/lock/nello.py @pschmitt
|
||||
@@ -65,11 +67,14 @@ homeassistant/components/media_player/sonos.py @amelchio
|
||||
homeassistant/components/media_player/xiaomi_tv.py @fattdev
|
||||
homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth
|
||||
homeassistant/components/plant.py @ChristianKuehnel
|
||||
homeassistant/components/scene/lifx_cloud.py @amelchio
|
||||
homeassistant/components/sensor/airvisual.py @bachya
|
||||
homeassistant/components/sensor/filter.py @dgomes
|
||||
homeassistant/components/sensor/gearbest.py @HerrHofrat
|
||||
homeassistant/components/sensor/irish_rail_transport.py @ttroy50
|
||||
homeassistant/components/sensor/jewish_calendar.py @tsvi
|
||||
homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel
|
||||
homeassistant/components/sensor/nsw_fuel_station.py @nickw444
|
||||
homeassistant/components/sensor/pollen.py @bachya
|
||||
homeassistant/components/sensor/qnap.py @colinodell
|
||||
homeassistant/components/sensor/sma.py @kellerza
|
||||
@@ -78,7 +83,6 @@ homeassistant/components/sensor/sytadin.py @gautric
|
||||
homeassistant/components/sensor/tibber.py @danielhiversen
|
||||
homeassistant/components/sensor/upnp.py @dgomes
|
||||
homeassistant/components/sensor/waqi.py @andrey-git
|
||||
homeassistant/components/switch/rainmachine.py @bachya
|
||||
homeassistant/components/switch/tplink.py @rytilahti
|
||||
homeassistant/components/vacuum/roomba.py @pschmitt
|
||||
homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi
|
||||
@@ -87,19 +91,27 @@ homeassistant/components/*/axis.py @kane610
|
||||
homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/*/broadlink.py @danielhiversen
|
||||
homeassistant/components/*/deconz.py @kane610
|
||||
homeassistant/components/ecovacs.py @OverloadUT
|
||||
homeassistant/components/*/ecovacs.py @OverloadUT
|
||||
homeassistant/components/eight_sleep.py @mezz64
|
||||
homeassistant/components/*/eight_sleep.py @mezz64
|
||||
homeassistant/components/hive.py @Rendili @KJonline
|
||||
homeassistant/components/*/hive.py @Rendili @KJonline
|
||||
homeassistant/components/homekit/* @cdce8p
|
||||
homeassistant/components/huawei_lte.py @scop
|
||||
homeassistant/components/*/huawei_lte.py @scop
|
||||
homeassistant/components/knx.py @Julius2342
|
||||
homeassistant/components/*/knx.py @Julius2342
|
||||
homeassistant/components/konnected.py @heythisisnate
|
||||
homeassistant/components/*/konnected.py @heythisisnate
|
||||
homeassistant/components/matrix.py @tinloaf
|
||||
homeassistant/components/*/matrix.py @tinloaf
|
||||
homeassistant/components/openuv.py @bachya
|
||||
homeassistant/components/*/openuv.py @bachya
|
||||
homeassistant/components/qwikswitch.py @kellerza
|
||||
homeassistant/components/*/qwikswitch.py @kellerza
|
||||
homeassistant/components/rainmachine/* @bachya
|
||||
homeassistant/components/*/rainmachine.py @bachya
|
||||
homeassistant/components/*/rfxtrx.py @danielhiversen
|
||||
homeassistant/components/tahoma.py @philklei
|
||||
homeassistant/components/*/tahoma.py @philklei
|
||||
@@ -108,9 +120,13 @@ homeassistant/components/*/tesla.py @zabuldon
|
||||
homeassistant/components/tellduslive.py @molobrakos @fredrike
|
||||
homeassistant/components/*/tellduslive.py @molobrakos @fredrike
|
||||
homeassistant/components/*/tradfri.py @ggravlingen
|
||||
homeassistant/components/upcloud.py @scop
|
||||
homeassistant/components/*/upcloud.py @scop
|
||||
homeassistant/components/velux.py @Julius2342
|
||||
homeassistant/components/*/velux.py @Julius2342
|
||||
homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi
|
||||
homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi
|
||||
homeassistant/components/zoneminder.py @rohankapoorcom
|
||||
homeassistant/components/*/zoneminder.py @rohankapoorcom
|
||||
|
||||
homeassistant/scripts/check_config.py @kellerza
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing to Home Assistant
|
||||
|
||||
Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spent a couple of hours and help to integrate them?
|
||||
Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spend a couple of hours and help to integrate them?
|
||||
|
||||
The process is straight-forward.
|
||||
|
||||
@@ -10,5 +10,5 @@ The process is straight-forward.
|
||||
- Ensure tests work.
|
||||
- Create a Pull Request against the [**dev**](https://github.com/home-assistant/home-assistant/tree/dev) branch of Home Assistant.
|
||||
|
||||
Still interested? Then you should take a peek at the [developer documentation](https://home-assistant.io/developers/) to get more details.
|
||||
Still interested? Then you should take a peek at the [developer documentation](https://developers.home-assistant.io/) to get more details.
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ LABEL maintainer="Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>"
|
||||
#ENV INSTALL_OPENALPR no
|
||||
#ENV INSTALL_FFMPEG no
|
||||
#ENV INSTALL_LIBCEC no
|
||||
#ENV INSTALL_PHANTOMJS no
|
||||
#ENV INSTALL_SSOCR no
|
||||
#ENV INSTALL_IPERF3 no
|
||||
|
||||
VOLUME /config
|
||||
|
||||
|
||||
331
LICENSE.md
331
LICENSE.md
@@ -1,194 +1,201 @@
|
||||
Apache License
|
||||
==============
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
### Terms and Conditions for use, reproduction, and distribution
|
||||
1. Definitions.
|
||||
|
||||
#### 1. Definitions
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
“License” shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
“Licensor” shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
“Legal Entity” shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, “control” means **(i)** the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or **(iii)** beneficial ownership of such entity.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
“You” (or “Your”) shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
“Source” form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
“Object” form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
“Work” shall mean the work of authorship, whether in Source or Object form, made
|
||||
available under the License, as indicated by a copyright notice that is included
|
||||
in or attached to the work (an example is provided in the Appendix below).
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
“Derivative Works” shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
“Contribution” shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
“submitted” means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as “Not a Contribution.”
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
#### 2. Grant of Copyright License
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
#### 3. Grant of Patent License
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except as stated in this section) patent license to make, have
|
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
|
||||
such license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by combination
|
||||
of their Contribution(s) with the Work to which such Contribution(s) was
|
||||
submitted. If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this License
|
||||
for that Work shall terminate as of the date such litigation is filed.
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
#### 4. Redistribution
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof
|
||||
in any medium, with or without modifications, and in Source or Object form,
|
||||
provided that You meet the following conditions:
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
* **(b)** You must cause any modified files to carry prominent notices stating that You
|
||||
changed the files; and
|
||||
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
|
||||
all copyright, patent, trademark, and attribution notices from the Source form
|
||||
of the Work, excluding those notices that do not pertain to any part of the
|
||||
Derivative Works; and
|
||||
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
|
||||
Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those notices
|
||||
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||
following places: within a NOTICE text file distributed as part of the
|
||||
Derivative Works; within the Source form or documentation, if provided along
|
||||
with the Derivative Works; or, within a display generated by the Derivative
|
||||
Works, if and wherever such third-party notices normally appear. The contents of
|
||||
the NOTICE file are for informational purposes only and do not modify the
|
||||
License. You may add Your own attribution notices within Derivative Works that
|
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||
provided that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
#### 5. Submission of Contributions
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted
|
||||
for inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of
|
||||
any separate license agreement you may have executed with Licensor regarding
|
||||
such Contributions.
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
#### 6. Trademarks
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
This License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
#### 7. Disclaimer of Warranty
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the
|
||||
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
|
||||
including, without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
|
||||
solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise of
|
||||
permissions under this License.
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
#### 8. Limitation of Liability
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
In no event and under no legal theory, whether in tort (including negligence),
|
||||
contract, or otherwise, unless required by applicable law (such as deliberate
|
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special, incidental,
|
||||
or consequential damages of any character arising as a result of this License or
|
||||
out of the use or inability to use the Work (including but not limited to
|
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
|
||||
any and all other commercial damages or losses), even if such Contributor has
|
||||
been advised of the possibility of such damages.
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
#### 9. Accepting Warranty or Additional Liability
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to
|
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
|
||||
other liability obligations and/or rights consistent with this License. However,
|
||||
in accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if You
|
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
_END OF TERMS AND CONDITIONS_
|
||||
|
||||
### APPENDIX: How to apply the Apache License to your work
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate
|
||||
notice, with the fields enclosed by brackets `[]` replaced with your own
|
||||
identifying information. (Don't include the brackets!) The text should be
|
||||
enclosed in the appropriate comment syntax for the file format. We also
|
||||
recommend that a file or class name and description of purpose be included on
|
||||
the same “printed page” as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
10
README.rst
10
README.rst
@@ -1,5 +1,5 @@
|
||||
Home Assistant |Build Status| |Coverage Status| |Chat Status|
|
||||
=============================================================
|
||||
Home Assistant |Build Status| |Coverage Status| |Chat Status| |Reviewed by Hound|
|
||||
=================================================================================
|
||||
|
||||
Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control.
|
||||
|
||||
@@ -21,8 +21,8 @@ Featured integrations
|
||||
|
||||
|screenshot-components|
|
||||
|
||||
The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture <https://home-assistant.io/developers/architecture/>`__ and the `section on creating your own
|
||||
components <https://home-assistant.io/developers/creating_components/>`__.
|
||||
The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture <https://developers.home-assistant.io/docs/en/architecture_index.html>`__ and the `section on creating your own
|
||||
components <https://developers.home-assistant.io/docs/en/creating_component_index.html>`__.
|
||||
|
||||
If you run into issues while using Home Assistant or during development
|
||||
of a component, check the `Home Assistant help section <https://home-assistant.io/help/>`__ of our website for further help and information.
|
||||
@@ -33,6 +33,8 @@ of a component, check the `Home Assistant help section <https://home-assistant.i
|
||||
:target: https://coveralls.io/r/home-assistant/home-assistant?branch=master
|
||||
.. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg
|
||||
:target: https://discord.gg/c5DvZ4e
|
||||
.. |Reviewed by Hound| image:: https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg
|
||||
:target: https://houndci.com
|
||||
.. |screenshot-states| image:: https://raw.github.com/home-assistant/home-assistant/master/docs/screenshots.png
|
||||
:target: https://home-assistant.io/demo/
|
||||
.. |screenshot-components| image:: https://raw.github.com/home-assistant/home-assistant/dev/docs/screenshot-components.png
|
||||
|
||||
@@ -60,14 +60,6 @@ loader module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
remote module
|
||||
---------------------------
|
||||
|
||||
.. automodule:: homeassistant.remote
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
@@ -19,4 +19,4 @@ Indices and tables
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
.. _Home Assistant developers: https://home-assistant.io/developers/
|
||||
.. _Home Assistant developers: https://developers.home-assistant.io/
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Home Assistant
|
||||
description: Home Assistant REST API
|
||||
version: "1.0.1"
|
||||
# the domain of the service
|
||||
host: localhost:8123
|
||||
|
||||
# array of all schemes that your API supports
|
||||
schemes:
|
||||
- http
|
||||
- https
|
||||
|
||||
securityDefinitions:
|
||||
#api_key:
|
||||
# type: apiKey
|
||||
# description: API password
|
||||
# name: api_password
|
||||
# in: query
|
||||
|
||||
api_key:
|
||||
type: apiKey
|
||||
description: API password
|
||||
name: x-ha-access
|
||||
in: header
|
||||
|
||||
# will be prefixed to all paths
|
||||
basePath: /api
|
||||
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
summary: API alive message
|
||||
description: Returns message if API is up and running.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: API is up and running
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/config:
|
||||
get:
|
||||
summary: API alive message
|
||||
description: Returns the current configuration as JSON.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: Current configuration
|
||||
schema:
|
||||
$ref: '#/definitions/ApiConfig'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/discovery_info:
|
||||
get:
|
||||
summary: Basic information about Home Assistant instance
|
||||
tags:
|
||||
- Core
|
||||
responses:
|
||||
200:
|
||||
description: Basic information
|
||||
schema:
|
||||
$ref: '#/definitions/DiscoveryInfo'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/bootstrap:
|
||||
get:
|
||||
summary: Returns all data needed to bootstrap Home Assistant.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: Bootstrap information
|
||||
schema:
|
||||
$ref: '#/definitions/BootstrapInfo'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/events:
|
||||
get:
|
||||
summary: Array of event objects.
|
||||
description: Returns an array of event objects. Each event object contain event name and listener count.
|
||||
tags:
|
||||
- Events
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: Events
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Event'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/services:
|
||||
get:
|
||||
summary: Array of service objects.
|
||||
description: Returns an array of service objects. Each object contains the domain and which services it contains.
|
||||
tags:
|
||||
- Services
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: Services
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Service'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/history:
|
||||
get:
|
||||
summary: Array of state changes in the past.
|
||||
description: Returns an array of state changes in the past. Each object contains further detail for the entities.
|
||||
tags:
|
||||
- State
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: State changes
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/History'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/states:
|
||||
get:
|
||||
summary: Array of state objects.
|
||||
description: |
|
||||
Returns an array of state objects. Each state has the following attributes: entity_id, state, last_changed and attributes.
|
||||
tags:
|
||||
- State
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: States
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/State'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/states/{entity_id}:
|
||||
get:
|
||||
summary: Specific state object.
|
||||
description: |
|
||||
Returns a state object for specified entity_id.
|
||||
tags:
|
||||
- State
|
||||
security:
|
||||
- api_key: []
|
||||
parameters:
|
||||
- name: entity_id
|
||||
in: path
|
||||
description: entity_id of the entity to query
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: State
|
||||
schema:
|
||||
$ref: '#/definitions/State'
|
||||
404:
|
||||
description: Not found
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
post:
|
||||
description: |
|
||||
Updates or creates the current state of an entity.
|
||||
tags:
|
||||
- State
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- name: entity_id
|
||||
in: path
|
||||
description: entity_id to set the state of
|
||||
required: true
|
||||
type: string
|
||||
- $ref: '#/parameters/State'
|
||||
responses:
|
||||
200:
|
||||
description: State of existing entity was set
|
||||
schema:
|
||||
$ref: '#/definitions/State'
|
||||
201:
|
||||
description: State of new entity was set
|
||||
schema:
|
||||
$ref: '#/definitions/State'
|
||||
headers:
|
||||
location:
|
||||
type: string
|
||||
description: location of the new entity
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/error_log:
|
||||
get:
|
||||
summary: Error log
|
||||
description: |
|
||||
Retrieve all errors logged during the current session of Home Assistant as a plaintext response.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
200:
|
||||
description: Plain text error log
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/camera_proxy/camera.{entity_id}:
|
||||
get:
|
||||
summary: Camera image.
|
||||
description: |
|
||||
Returns the data (image) from the specified camera entity_id.
|
||||
tags:
|
||||
- Camera
|
||||
security:
|
||||
- api_key: []
|
||||
produces:
|
||||
- image/jpeg
|
||||
parameters:
|
||||
- name: entity_id
|
||||
in: path
|
||||
description: entity_id of the camera to query
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: Camera image
|
||||
schema:
|
||||
type: file
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/events/{event_type}:
|
||||
post:
|
||||
description: |
|
||||
Fires an event with event_type
|
||||
tags:
|
||||
- Events
|
||||
security:
|
||||
- api_key: []
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- name: event_type
|
||||
in: path
|
||||
description: event_type to fire event with
|
||||
required: true
|
||||
type: string
|
||||
- $ref: '#/parameters/EventData'
|
||||
responses:
|
||||
200:
|
||||
description: Response message
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/services/{domain}/{service}:
|
||||
post:
|
||||
description: |
|
||||
Calls a service within a specific domain. Will return when the service has been executed or 10 seconds has past, whichever comes first.
|
||||
tags:
|
||||
- Services
|
||||
security:
|
||||
- api_key: []
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- name: domain
|
||||
in: path
|
||||
description: domain of the service
|
||||
required: true
|
||||
type: string
|
||||
- name: service
|
||||
in: path
|
||||
description: service to call
|
||||
required: true
|
||||
type: string
|
||||
- $ref: '#/parameters/ServiceData'
|
||||
responses:
|
||||
200:
|
||||
description: List of states that have changed while the service was being executed. The result will include any changed states that changed while the service was being executed, even if their change was the result of something else happening in the system.
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/State'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/template:
|
||||
post:
|
||||
description: |
|
||||
Render a Home Assistant template.
|
||||
tags:
|
||||
- Template
|
||||
security:
|
||||
- api_key: []
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- text/plain
|
||||
parameters:
|
||||
- $ref: '#/parameters/Template'
|
||||
responses:
|
||||
200:
|
||||
description: Returns the rendered template in plain text.
|
||||
schema:
|
||||
type: string
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/event_forwarding:
|
||||
post:
|
||||
description: |
|
||||
Setup event forwarding to another Home Assistant instance.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- $ref: '#/parameters/EventForwarding'
|
||||
responses:
|
||||
200:
|
||||
description: It will return a message if event forwarding was setup successful.
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
delete:
|
||||
description: |
|
||||
Cancel event forwarding to another Home Assistant instance.
|
||||
tags:
|
||||
- Core
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- $ref: '#/parameters/EventForwarding'
|
||||
responses:
|
||||
200:
|
||||
description: It will return a message if event forwarding was cancelled successful.
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/stream:
|
||||
get:
|
||||
summary: Server-sent events
|
||||
description: The server-sent events feature is a one-way channel from your Home Assistant server to a client which is acting as a consumer.
|
||||
tags:
|
||||
- Core
|
||||
- Events
|
||||
security:
|
||||
- api_key: []
|
||||
produces:
|
||||
- text/event-stream
|
||||
parameters:
|
||||
- name: restrict
|
||||
in: query
|
||||
description: comma-separated list of event_types to filter
|
||||
required: false
|
||||
type: string
|
||||
responses:
|
||||
default:
|
||||
description: Stream of events
|
||||
schema:
|
||||
type: object
|
||||
x-events:
|
||||
state_changed:
|
||||
type: object
|
||||
properties:
|
||||
entity_id:
|
||||
type: string
|
||||
old_state:
|
||||
$ref: '#/definitions/State'
|
||||
new_state:
|
||||
$ref: '#/definitions/State'
|
||||
definitions:
|
||||
ApiConfig:
|
||||
type: object
|
||||
properties:
|
||||
components:
|
||||
type: array
|
||||
description: List of component types
|
||||
items:
|
||||
type: string
|
||||
description: Component type
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
description: Latitude of Home Assistant server
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
description: Longitude of Home Assistant server
|
||||
location_name:
|
||||
type: string
|
||||
unit_system:
|
||||
type: object
|
||||
properties:
|
||||
length:
|
||||
type: string
|
||||
mass:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
volume:
|
||||
type: string
|
||||
time_zone:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
DiscoveryInfo:
|
||||
type: object
|
||||
properties:
|
||||
base_url:
|
||||
type: string
|
||||
location_name:
|
||||
type: string
|
||||
requires_api_password:
|
||||
type: boolean
|
||||
version:
|
||||
type: string
|
||||
BootstrapInfo:
|
||||
type: object
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/definitions/ApiConfig'
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Event'
|
||||
services:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Service'
|
||||
states:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/State'
|
||||
Event:
|
||||
type: object
|
||||
properties:
|
||||
event:
|
||||
type: string
|
||||
listener_count:
|
||||
type: integer
|
||||
Service:
|
||||
type: object
|
||||
properties:
|
||||
domain:
|
||||
type: string
|
||||
services:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/DomainService'
|
||||
DomainService:
|
||||
type: object
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
fields:
|
||||
type: object
|
||||
description: Object with service fields that can be called
|
||||
State:
|
||||
type: object
|
||||
properties:
|
||||
attributes:
|
||||
$ref: '#/definitions/StateAttributes'
|
||||
state:
|
||||
type: string
|
||||
entity_id:
|
||||
type: string
|
||||
last_changed:
|
||||
type: string
|
||||
format: date-time
|
||||
StateAttributes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
History:
|
||||
allOf:
|
||||
- $ref: '#/definitions/State'
|
||||
- type: object
|
||||
properties:
|
||||
last_updated:
|
||||
type: string
|
||||
format: date-time
|
||||
Message:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
parameters:
|
||||
State:
|
||||
name: body
|
||||
in: body
|
||||
description: State parameter
|
||||
required: false
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
properties:
|
||||
attributes:
|
||||
$ref: '#/definitions/StateAttributes'
|
||||
state:
|
||||
type: string
|
||||
EventData:
|
||||
name: body
|
||||
in: body
|
||||
description: event_data
|
||||
required: false
|
||||
schema:
|
||||
type: object
|
||||
ServiceData:
|
||||
name: body
|
||||
in: body
|
||||
description: service_data
|
||||
required: false
|
||||
schema:
|
||||
type: object
|
||||
Template:
|
||||
name: body
|
||||
in: body
|
||||
description: Template to render
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- template
|
||||
properties:
|
||||
template:
|
||||
description: Jinja2 template string
|
||||
type: string
|
||||
EventForwarding:
|
||||
name: body
|
||||
in: body
|
||||
description: Event Forwarding parameter
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- host
|
||||
- api_password
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
api_password:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
@@ -7,8 +7,7 @@ import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from typing import Optional, List, Dict, Any # noqa #pylint: disable=unused-import
|
||||
from typing import List, Dict, Any # noqa pylint: disable=unused-import
|
||||
|
||||
|
||||
from homeassistant import monkey_patch
|
||||
@@ -20,15 +19,19 @@ from homeassistant.const import (
|
||||
)
|
||||
|
||||
|
||||
def attempt_use_uvloop():
|
||||
def set_loop() -> None:
|
||||
"""Attempt to use uvloop."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
import uvloop
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
except ImportError:
|
||||
pass
|
||||
if sys.platform == 'win32':
|
||||
asyncio.set_event_loop(asyncio.ProactorEventLoop())
|
||||
else:
|
||||
try:
|
||||
import uvloop
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
|
||||
|
||||
def validate_python() -> None:
|
||||
@@ -240,48 +243,36 @@ def cmdline() -> List[str]:
|
||||
return [arg for arg in sys.argv if arg != '--daemon']
|
||||
|
||||
|
||||
def setup_and_run_hass(config_dir: str,
|
||||
args: argparse.Namespace) -> Optional[int]:
|
||||
async def setup_and_run_hass(config_dir: str,
|
||||
args: argparse.Namespace) -> int:
|
||||
"""Set up HASS and run."""
|
||||
from homeassistant import bootstrap
|
||||
from homeassistant import bootstrap, core
|
||||
|
||||
# Run a simple daemon runner process on Windows to handle restarts
|
||||
if os.name == 'nt' and '--runner' not in sys.argv:
|
||||
nt_args = cmdline() + ['--runner']
|
||||
while True:
|
||||
try:
|
||||
subprocess.check_call(nt_args)
|
||||
sys.exit(0)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if exc.returncode != RESTART_EXIT_CODE:
|
||||
sys.exit(exc.returncode)
|
||||
hass = core.HomeAssistant()
|
||||
|
||||
if args.demo_mode:
|
||||
config = {
|
||||
'frontend': {},
|
||||
'demo': {}
|
||||
} # type: Dict[str, Any]
|
||||
hass = bootstrap.from_config_dict(
|
||||
config, config_dir=config_dir, verbose=args.verbose,
|
||||
bootstrap.async_from_config_dict(
|
||||
config, hass, config_dir=config_dir, verbose=args.verbose,
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days,
|
||||
log_file=args.log_file, log_no_color=args.log_no_color)
|
||||
else:
|
||||
config_file = ensure_config_file(config_dir)
|
||||
print('Config directory:', config_dir)
|
||||
hass = bootstrap.from_config_file(
|
||||
config_file, verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
await bootstrap.async_from_config_file(
|
||||
config_file, hass, verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days, log_file=args.log_file,
|
||||
log_no_color=args.log_no_color)
|
||||
|
||||
if hass is None:
|
||||
return None
|
||||
|
||||
if args.open_ui:
|
||||
# Imported here to avoid importing asyncio before monkey patch
|
||||
from homeassistant.util.async_ import run_callback_threadsafe
|
||||
|
||||
def open_browser(event):
|
||||
"""Open the webinterface in a browser."""
|
||||
def open_browser(_: Any) -> None:
|
||||
"""Open the web interface in a browser."""
|
||||
if hass.config.api is not None:
|
||||
import webbrowser
|
||||
webbrowser.open(hass.config.api.base_url)
|
||||
@@ -292,7 +283,7 @@ def setup_and_run_hass(config_dir: str,
|
||||
EVENT_HOMEASSISTANT_START, open_browser
|
||||
)
|
||||
|
||||
return hass.start()
|
||||
return await hass.async_run()
|
||||
|
||||
|
||||
def try_to_restart() -> None:
|
||||
@@ -347,7 +338,20 @@ def main() -> int:
|
||||
monkey_patch.disable_c_asyncio()
|
||||
monkey_patch.patch_weakref_tasks()
|
||||
|
||||
attempt_use_uvloop()
|
||||
set_loop()
|
||||
|
||||
# Run a simple daemon runner process on Windows to handle restarts
|
||||
if os.name == 'nt' and '--runner' not in sys.argv:
|
||||
nt_args = cmdline() + ['--runner']
|
||||
while True:
|
||||
try:
|
||||
subprocess.check_call(nt_args)
|
||||
sys.exit(0)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if exc.returncode != RESTART_EXIT_CODE:
|
||||
sys.exit(exc.returncode)
|
||||
|
||||
args = get_arguments()
|
||||
|
||||
@@ -366,11 +370,12 @@ def main() -> int:
|
||||
if args.pid_file:
|
||||
write_pid(args.pid_file)
|
||||
|
||||
exit_code = setup_and_run_hass(config_dir, args)
|
||||
from homeassistant.util.async_ import asyncio_run
|
||||
exit_code = asyncio_run(setup_and_run_hass(config_dir, args))
|
||||
if exit_code == RESTART_EXIT_CODE and not args.runner:
|
||||
try_to_restart()
|
||||
|
||||
return exit_code
|
||||
return exit_code # type: ignore # mypy cannot yet infer it
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
"""Provide an authentication layer for Home Assistant."""
|
||||
import asyncio
|
||||
import binascii
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import importlib
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import attr
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant import data_entry_flow, requirements
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
AUTH_PROVIDERS = Registry()
|
||||
|
||||
AUTH_PROVIDER_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Optional(CONF_NAME): str,
|
||||
# Specify ID if you have two auth providers for same type.
|
||||
vol.Optional(CONF_ID): str,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
|
||||
DATA_REQS = 'auth_reqs_processed'
|
||||
|
||||
|
||||
def generate_secret(entropy: int = 32) -> str:
|
||||
"""Generate a secret.
|
||||
|
||||
Backport of secrets.token_hex from Python 3.6
|
||||
|
||||
Event loop friendly.
|
||||
"""
|
||||
return binascii.hexlify(os.urandom(entropy)).decode('ascii')
|
||||
|
||||
|
||||
class AuthProvider:
|
||||
"""Provider of user authentication."""
|
||||
|
||||
DEFAULT_TITLE = 'Unnamed auth provider'
|
||||
|
||||
initialized = False
|
||||
|
||||
def __init__(self, hass, store, config):
|
||||
"""Initialize an auth provider."""
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def id(self): # pylint: disable=invalid-name
|
||||
"""Return id of the auth provider.
|
||||
|
||||
Optional, can be None.
|
||||
"""
|
||||
return self.config.get(CONF_ID)
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Return type of the provider."""
|
||||
return self.config[CONF_TYPE]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the auth provider."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
|
||||
async def async_credentials(self):
|
||||
"""Return all credentials of this provider."""
|
||||
return await self.store.credentials_for_provider(self.type, self.id)
|
||||
|
||||
@callback
|
||||
def async_create_credentials(self, data):
|
||||
"""Create credentials."""
|
||||
return Credentials(
|
||||
auth_provider_type=self.type,
|
||||
auth_provider_id=self.id,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Implement by extending class
|
||||
|
||||
async def async_initialize(self):
|
||||
"""Initialize the auth provider.
|
||||
|
||||
Optional.
|
||||
"""
|
||||
|
||||
async def async_credential_flow(self):
|
||||
"""Return the data flow for logging in with auth provider."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
"""Get credentials based on the flow result."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class User:
|
||||
"""A user."""
|
||||
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_owner = attr.ib(type=bool, default=False)
|
||||
is_active = attr.ib(type=bool, default=False)
|
||||
name = attr.ib(type=str, default=None)
|
||||
# For persisting and see if saved?
|
||||
# store = attr.ib(type=AuthStore, default=None)
|
||||
|
||||
# List of credentials of a user.
|
||||
credentials = attr.ib(type=list, default=attr.Factory(list))
|
||||
|
||||
# Tokens associated with a user.
|
||||
refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict))
|
||||
|
||||
def as_dict(self):
|
||||
"""Convert user object to a dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'is_owner': self.is_owner,
|
||||
'is_active': self.is_active,
|
||||
'name': self.name,
|
||||
}
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class RefreshToken:
|
||||
"""RefreshToken for a user to grant new access tokens."""
|
||||
|
||||
user = attr.ib(type=User)
|
||||
client_id = attr.ib(type=str)
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow))
|
||||
access_token_expiration = attr.ib(type=timedelta,
|
||||
default=ACCESS_TOKEN_EXPIRATION)
|
||||
token = attr.ib(type=str,
|
||||
default=attr.Factory(lambda: generate_secret(64)))
|
||||
access_tokens = attr.ib(type=list, default=attr.Factory(list))
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class AccessToken:
|
||||
"""Access token to access the API.
|
||||
|
||||
These will only ever be stored in memory and not be persisted.
|
||||
"""
|
||||
|
||||
refresh_token = attr.ib(type=RefreshToken)
|
||||
created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow))
|
||||
token = attr.ib(type=str,
|
||||
default=attr.Factory(generate_secret))
|
||||
|
||||
@property
|
||||
def expires(self):
|
||||
"""Return datetime when this token expires."""
|
||||
return self.created_at + self.refresh_token.access_token_expiration
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Credentials:
|
||||
"""Credentials for a user on an auth provider."""
|
||||
|
||||
auth_provider_type = attr.ib(type=str)
|
||||
auth_provider_id = attr.ib(type=str)
|
||||
|
||||
# Allow the auth provider to store data to represent their auth.
|
||||
data = attr.ib(type=dict)
|
||||
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_new = attr.ib(type=bool, default=True)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Client:
|
||||
"""Client that interacts with Home Assistant on behalf of a user."""
|
||||
|
||||
name = attr.ib(type=str)
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
secret = attr.ib(type=str, default=attr.Factory(generate_secret))
|
||||
redirect_uris = attr.ib(type=list, default=attr.Factory(list))
|
||||
|
||||
|
||||
async def load_auth_provider_module(hass, provider):
|
||||
"""Load an auth provider."""
|
||||
try:
|
||||
module = importlib.import_module(
|
||||
'homeassistant.auth_providers.{}'.format(provider))
|
||||
except ImportError:
|
||||
_LOGGER.warning('Unable to find auth provider %s', provider)
|
||||
return None
|
||||
|
||||
if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'):
|
||||
return module
|
||||
|
||||
processed = hass.data.get(DATA_REQS)
|
||||
|
||||
if processed is None:
|
||||
processed = hass.data[DATA_REQS] = set()
|
||||
elif provider in processed:
|
||||
return module
|
||||
|
||||
req_success = await requirements.async_process_requirements(
|
||||
hass, 'auth provider {}'.format(provider), module.REQUIREMENTS)
|
||||
|
||||
if not req_success:
|
||||
return None
|
||||
|
||||
return module
|
||||
|
||||
|
||||
async def auth_manager_from_config(hass, provider_configs):
|
||||
"""Initialize an auth manager from config."""
|
||||
store = AuthStore(hass)
|
||||
if provider_configs:
|
||||
providers = await asyncio.gather(
|
||||
*[_auth_provider_from_config(hass, store, config)
|
||||
for config in provider_configs])
|
||||
else:
|
||||
providers = []
|
||||
# So returned auth providers are in same order as config
|
||||
provider_hash = OrderedDict()
|
||||
for provider in providers:
|
||||
if provider is None:
|
||||
continue
|
||||
|
||||
key = (provider.type, provider.id)
|
||||
|
||||
if key in provider_hash:
|
||||
_LOGGER.error(
|
||||
'Found duplicate provider: %s. Please add unique IDs if you '
|
||||
'want to have the same provider twice.', key)
|
||||
continue
|
||||
|
||||
provider_hash[key] = provider
|
||||
manager = AuthManager(hass, store, provider_hash)
|
||||
return manager
|
||||
|
||||
|
||||
async def _auth_provider_from_config(hass, store, config):
|
||||
"""Initialize an auth provider from a config."""
|
||||
provider_name = config[CONF_TYPE]
|
||||
module = await load_auth_provider_module(hass, provider_name)
|
||||
|
||||
if module is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
config = module.CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Invalid configuration for auth provider %s: %s',
|
||||
provider_name, humanize_error(config, err))
|
||||
return None
|
||||
|
||||
return AUTH_PROVIDERS[provider_name](hass, store, config)
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manage the authentication for Home Assistant."""
|
||||
|
||||
def __init__(self, hass, store, providers):
|
||||
"""Initialize the auth manager."""
|
||||
self._store = store
|
||||
self._providers = providers
|
||||
self.login_flow = data_entry_flow.FlowManager(
|
||||
hass, self._async_create_login_flow,
|
||||
self._async_finish_login_flow)
|
||||
self.access_tokens = {}
|
||||
|
||||
@property
|
||||
def async_auth_providers(self):
|
||||
"""Return a list of available auth providers."""
|
||||
return self._providers.values()
|
||||
|
||||
async def async_get_user(self, user_id):
|
||||
"""Retrieve a user."""
|
||||
return await self._store.async_get_user(user_id)
|
||||
|
||||
async def async_get_or_create_user(self, credentials):
|
||||
"""Get or create a user."""
|
||||
return await self._store.async_get_or_create_user(
|
||||
credentials, self._async_get_auth_provider(credentials))
|
||||
|
||||
async def async_link_user(self, user, credentials):
|
||||
"""Link credentials to an existing user."""
|
||||
await self._store.async_link_user(user, credentials)
|
||||
|
||||
async def async_remove_user(self, user):
|
||||
"""Remove a user."""
|
||||
await self._store.async_remove_user(user)
|
||||
|
||||
async def async_create_refresh_token(self, user, client_id):
|
||||
"""Create a new refresh token for a user."""
|
||||
return await self._store.async_create_refresh_token(user, client_id)
|
||||
|
||||
async def async_get_refresh_token(self, token):
|
||||
"""Get refresh token by token."""
|
||||
return await self._store.async_get_refresh_token(token)
|
||||
|
||||
@callback
|
||||
def async_create_access_token(self, refresh_token):
|
||||
"""Create a new access token."""
|
||||
access_token = AccessToken(refresh_token)
|
||||
self.access_tokens[access_token.token] = access_token
|
||||
return access_token
|
||||
|
||||
@callback
|
||||
def async_get_access_token(self, token):
|
||||
"""Get an access token."""
|
||||
return self.access_tokens.get(token)
|
||||
|
||||
async def async_create_client(self, name, *, redirect_uris=None,
|
||||
no_secret=False):
|
||||
"""Create a new client."""
|
||||
return await self._store.async_create_client(
|
||||
name, redirect_uris, no_secret)
|
||||
|
||||
async def async_get_client(self, client_id):
|
||||
"""Get a client."""
|
||||
return await self._store.async_get_client(client_id)
|
||||
|
||||
async def _async_create_login_flow(self, handler, *, source, data):
|
||||
"""Create a login flow."""
|
||||
auth_provider = self._providers[handler]
|
||||
|
||||
if not auth_provider.initialized:
|
||||
auth_provider.initialized = True
|
||||
await auth_provider.async_initialize()
|
||||
|
||||
return await auth_provider.async_credential_flow()
|
||||
|
||||
async def _async_finish_login_flow(self, result):
|
||||
"""Result of a credential login flow."""
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
return None
|
||||
|
||||
auth_provider = self._providers[result['handler']]
|
||||
return await auth_provider.async_get_or_create_credentials(
|
||||
result['data'])
|
||||
|
||||
@callback
|
||||
def _async_get_auth_provider(self, credentials):
|
||||
"""Helper to get auth provider from a set of credentials."""
|
||||
auth_provider_key = (credentials.auth_provider_type,
|
||||
credentials.auth_provider_id)
|
||||
return self._providers[auth_provider_key]
|
||||
|
||||
|
||||
class AuthStore:
|
||||
"""Stores authentication info.
|
||||
|
||||
Any mutation to an object should happen inside the auth store.
|
||||
|
||||
The auth store is lazy. It won't load the data from disk until a method is
|
||||
called that needs it.
|
||||
"""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize the auth store."""
|
||||
self.hass = hass
|
||||
self.users = None
|
||||
self.clients = None
|
||||
self._load_lock = asyncio.Lock(loop=hass.loop)
|
||||
|
||||
async def credentials_for_provider(self, provider_type, provider_id):
|
||||
"""Return credentials for specific auth provider type and id."""
|
||||
if self.users is None:
|
||||
await self.async_load()
|
||||
|
||||
return [
|
||||
credentials
|
||||
for user in self.users.values()
|
||||
for credentials in user.credentials
|
||||
if (credentials.auth_provider_type == provider_type and
|
||||
credentials.auth_provider_id == provider_id)
|
||||
]
|
||||
|
||||
async def async_get_user(self, user_id):
|
||||
"""Retrieve a user."""
|
||||
if self.users is None:
|
||||
await self.async_load()
|
||||
|
||||
return self.users.get(user_id)
|
||||
|
||||
async def async_get_or_create_user(self, credentials, auth_provider):
|
||||
"""Get or create a new user for given credentials.
|
||||
|
||||
If link_user is passed in, the credentials will be linked to the passed
|
||||
in user if the credentials are new.
|
||||
"""
|
||||
if self.users is None:
|
||||
await self.async_load()
|
||||
|
||||
# New credentials, store in user
|
||||
if credentials.is_new:
|
||||
info = await auth_provider.async_user_meta_for_credentials(
|
||||
credentials)
|
||||
# Make owner and activate user if it's the first user.
|
||||
if self.users:
|
||||
is_owner = False
|
||||
is_active = False
|
||||
else:
|
||||
is_owner = True
|
||||
is_active = True
|
||||
|
||||
new_user = User(
|
||||
is_owner=is_owner,
|
||||
is_active=is_active,
|
||||
name=info.get('name'),
|
||||
)
|
||||
self.users[new_user.id] = new_user
|
||||
await self.async_link_user(new_user, credentials)
|
||||
return new_user
|
||||
|
||||
for user in self.users.values():
|
||||
for creds in user.credentials:
|
||||
if (creds.auth_provider_type == credentials.auth_provider_type
|
||||
and creds.auth_provider_id ==
|
||||
credentials.auth_provider_id):
|
||||
return user
|
||||
|
||||
raise ValueError('We got credentials with ID but found no user')
|
||||
|
||||
async def async_link_user(self, user, credentials):
|
||||
"""Add credentials to an existing user."""
|
||||
user.credentials.append(credentials)
|
||||
await self.async_save()
|
||||
credentials.is_new = False
|
||||
|
||||
async def async_remove_user(self, user):
|
||||
"""Remove a user."""
|
||||
self.users.pop(user.id)
|
||||
await self.async_save()
|
||||
|
||||
async def async_create_refresh_token(self, user, client_id):
|
||||
"""Create a new token for a user."""
|
||||
refresh_token = RefreshToken(user, client_id)
|
||||
user.refresh_tokens[refresh_token.token] = refresh_token
|
||||
await self.async_save()
|
||||
return refresh_token
|
||||
|
||||
async def async_get_refresh_token(self, token):
|
||||
"""Get refresh token by token."""
|
||||
if self.users is None:
|
||||
await self.async_load()
|
||||
|
||||
for user in self.users.values():
|
||||
refresh_token = user.refresh_tokens.get(token)
|
||||
if refresh_token is not None:
|
||||
return refresh_token
|
||||
|
||||
return None
|
||||
|
||||
async def async_create_client(self, name, redirect_uris, no_secret):
|
||||
"""Create a new client."""
|
||||
if self.clients is None:
|
||||
await self.async_load()
|
||||
|
||||
kwargs = {
|
||||
'name': name,
|
||||
'redirect_uris': redirect_uris
|
||||
}
|
||||
|
||||
if no_secret:
|
||||
kwargs['secret'] = None
|
||||
|
||||
client = Client(**kwargs)
|
||||
self.clients[client.id] = client
|
||||
await self.async_save()
|
||||
return client
|
||||
|
||||
async def async_get_client(self, client_id):
|
||||
"""Get a client."""
|
||||
if self.clients is None:
|
||||
await self.async_load()
|
||||
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def async_load(self):
|
||||
"""Load the users."""
|
||||
async with self._load_lock:
|
||||
self.users = {}
|
||||
self.clients = {}
|
||||
|
||||
async def async_save(self):
|
||||
"""Save users."""
|
||||
pass
|
||||
420
homeassistant/auth/__init__.py
Normal file
420
homeassistant/auth/__init__.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""Provide an authentication layer for Home Assistant."""
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
|
||||
import jwt
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import auth_store, models
|
||||
from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule
|
||||
from .providers import auth_provider_from_config, AuthProvider, LoginFlow
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_MfaModuleDict = Dict[str, MultiFactorAuthModule]
|
||||
_ProviderKey = Tuple[str, Optional[str]]
|
||||
_ProviderDict = Dict[_ProviderKey, AuthProvider]
|
||||
|
||||
|
||||
async def auth_manager_from_config(
|
||||
hass: HomeAssistant,
|
||||
provider_configs: List[Dict[str, Any]],
|
||||
module_configs: List[Dict[str, Any]]) -> 'AuthManager':
|
||||
"""Initialize an auth manager from config.
|
||||
|
||||
CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or
|
||||
mfa modules exist in configs.
|
||||
"""
|
||||
store = auth_store.AuthStore(hass)
|
||||
if provider_configs:
|
||||
providers = await asyncio.gather(
|
||||
*[auth_provider_from_config(hass, store, config)
|
||||
for config in provider_configs])
|
||||
else:
|
||||
providers = ()
|
||||
# So returned auth providers are in same order as config
|
||||
provider_hash = OrderedDict() # type: _ProviderDict
|
||||
for provider in providers:
|
||||
key = (provider.type, provider.id)
|
||||
provider_hash[key] = provider
|
||||
|
||||
if module_configs:
|
||||
modules = await asyncio.gather(
|
||||
*[auth_mfa_module_from_config(hass, config)
|
||||
for config in module_configs])
|
||||
else:
|
||||
modules = ()
|
||||
# So returned auth modules are in same order as config
|
||||
module_hash = OrderedDict() # type: _MfaModuleDict
|
||||
for module in modules:
|
||||
module_hash[module.id] = module
|
||||
|
||||
manager = AuthManager(hass, store, provider_hash, module_hash)
|
||||
return manager
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manage the authentication for Home Assistant."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore,
|
||||
providers: _ProviderDict, mfa_modules: _MfaModuleDict) \
|
||||
-> None:
|
||||
"""Initialize the auth manager."""
|
||||
self.hass = hass
|
||||
self._store = store
|
||||
self._providers = providers
|
||||
self._mfa_modules = mfa_modules
|
||||
self.login_flow = data_entry_flow.FlowManager(
|
||||
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:
|
||||
"""
|
||||
Return if legacy_api_password auth providers are registered.
|
||||
|
||||
Should be removed when we removed legacy_api_password auth providers.
|
||||
"""
|
||||
for provider_type, _ in self._providers:
|
||||
if provider_type == 'legacy_api_password':
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def auth_providers(self) -> List[AuthProvider]:
|
||||
"""Return a list of available auth providers."""
|
||||
return list(self._providers.values())
|
||||
|
||||
@property
|
||||
def auth_mfa_modules(self) -> List[MultiFactorAuthModule]:
|
||||
"""Return a list of available auth modules."""
|
||||
return list(self._mfa_modules.values())
|
||||
|
||||
def get_auth_mfa_module(self, module_id: str) \
|
||||
-> Optional[MultiFactorAuthModule]:
|
||||
"""Return an multi-factor auth module, None if not found."""
|
||||
return self._mfa_modules.get(module_id)
|
||||
|
||||
async def async_get_users(self) -> List[models.User]:
|
||||
"""Retrieve all users."""
|
||||
return await self._store.async_get_users()
|
||||
|
||||
async def async_get_user(self, user_id: str) -> Optional[models.User]:
|
||||
"""Retrieve a user."""
|
||||
return await self._store.async_get_user(user_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."""
|
||||
for user in await self.async_get_users():
|
||||
for creds in user.credentials:
|
||||
if creds.id == credentials.id:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
async def async_create_system_user(self, name: str) -> models.User:
|
||||
"""Create a system user."""
|
||||
return await self._store.async_create_user(
|
||||
name=name,
|
||||
system_generated=True,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
async def async_create_user(self, name: str) -> models.User:
|
||||
"""Create a user."""
|
||||
kwargs = {
|
||||
'name': name,
|
||||
'is_active': True,
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if await self._user_should_be_owner():
|
||||
kwargs['is_owner'] = True
|
||||
|
||||
return await self._store.async_create_user(**kwargs)
|
||||
|
||||
async def async_get_or_create_user(self, credentials: models.Credentials) \
|
||||
-> models.User:
|
||||
"""Get or create a user."""
|
||||
if not credentials.is_new:
|
||||
user = await self.async_get_user_by_credentials(credentials)
|
||||
if user is None:
|
||||
raise ValueError('Unable to find the user.')
|
||||
else:
|
||||
return user
|
||||
|
||||
auth_provider = self._async_get_auth_provider(credentials)
|
||||
|
||||
if auth_provider is None:
|
||||
raise RuntimeError('Credential with unknown provider encountered')
|
||||
|
||||
info = await auth_provider.async_user_meta_for_credentials(
|
||||
credentials)
|
||||
|
||||
return await self._store.async_create_user(
|
||||
credentials=credentials,
|
||||
name=info.name,
|
||||
is_active=info.is_active,
|
||||
)
|
||||
|
||||
async def async_link_user(self, user: models.User,
|
||||
credentials: models.Credentials) -> None:
|
||||
"""Link credentials to an existing user."""
|
||||
await self._store.async_link_user(user, credentials)
|
||||
|
||||
async def async_remove_user(self, user: models.User) -> None:
|
||||
"""Remove a user."""
|
||||
tasks = [
|
||||
self.async_remove_credentials(credentials)
|
||||
for credentials in user.credentials
|
||||
]
|
||||
|
||||
if tasks:
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
await self._store.async_remove_user(user)
|
||||
|
||||
async def async_activate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
await self._store.async_activate_user(user)
|
||||
|
||||
async def async_deactivate_user(self, user: models.User) -> None:
|
||||
"""Deactivate a user."""
|
||||
if user.is_owner:
|
||||
raise ValueError('Unable to deactive the owner')
|
||||
await self._store.async_deactivate_user(user)
|
||||
|
||||
async def async_remove_credentials(
|
||||
self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
provider = self._async_get_auth_provider(credentials)
|
||||
|
||||
if (provider is not None and
|
||||
hasattr(provider, 'async_will_remove_credentials')):
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
await provider.async_will_remove_credentials( # type: ignore
|
||||
credentials)
|
||||
|
||||
await self._store.async_remove_credentials(credentials)
|
||||
|
||||
async def async_enable_user_mfa(self, user: models.User,
|
||||
mfa_module_id: str, data: Any) -> None:
|
||||
"""Enable a multi-factor auth module for user."""
|
||||
if user.system_generated:
|
||||
raise ValueError('System generated users cannot enable '
|
||||
'multi-factor auth module.')
|
||||
|
||||
module = self.get_auth_mfa_module(mfa_module_id)
|
||||
if module is None:
|
||||
raise ValueError('Unable find multi-factor auth module: {}'
|
||||
.format(mfa_module_id))
|
||||
|
||||
await module.async_setup_user(user.id, data)
|
||||
|
||||
async def async_disable_user_mfa(self, user: models.User,
|
||||
mfa_module_id: str) -> None:
|
||||
"""Disable a multi-factor auth module for user."""
|
||||
if user.system_generated:
|
||||
raise ValueError('System generated users cannot disable '
|
||||
'multi-factor auth module.')
|
||||
|
||||
module = self.get_auth_mfa_module(mfa_module_id)
|
||||
if module is None:
|
||||
raise ValueError('Unable find multi-factor auth module: {}'
|
||||
.format(mfa_module_id))
|
||||
|
||||
await module.async_depose_user(user.id)
|
||||
|
||||
async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]:
|
||||
"""List enabled mfa modules for user."""
|
||||
modules = OrderedDict() # type: Dict[str, str]
|
||||
for module_id, module in self._mfa_modules.items():
|
||||
if await module.async_is_user_setup(user.id):
|
||||
modules[module_id] = module.name
|
||||
return modules
|
||||
|
||||
async def async_create_refresh_token(
|
||||
self, user: models.User, client_id: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
client_icon: Optional[str] = None,
|
||||
token_type: Optional[str] = None,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \
|
||||
-> models.RefreshToken:
|
||||
"""Create a new refresh token for a user."""
|
||||
if not user.is_active:
|
||||
raise ValueError('User is not active')
|
||||
|
||||
if user.system_generated and client_id is not None:
|
||||
raise ValueError(
|
||||
'System generated users cannot have refresh tokens connected '
|
||||
'to a client.')
|
||||
|
||||
if token_type is None:
|
||||
if user.system_generated:
|
||||
token_type = models.TOKEN_TYPE_SYSTEM
|
||||
else:
|
||||
token_type = models.TOKEN_TYPE_NORMAL
|
||||
|
||||
if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM):
|
||||
raise ValueError(
|
||||
'System generated users can only have system type '
|
||||
'refresh tokens')
|
||||
|
||||
if token_type == models.TOKEN_TYPE_NORMAL and client_id is None:
|
||||
raise ValueError('Client is required to generate a refresh token.')
|
||||
|
||||
if (token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN and
|
||||
client_name is None):
|
||||
raise ValueError('Client_name is required for long-lived access '
|
||||
'token')
|
||||
|
||||
if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN:
|
||||
for token in user.refresh_tokens.values():
|
||||
if (token.client_name == client_name and token.token_type ==
|
||||
models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN):
|
||||
# Each client_name can only have one
|
||||
# long_lived_access_token type of refresh token
|
||||
raise ValueError('{} already exists'.format(client_name))
|
||||
|
||||
return await self._store.async_create_refresh_token(
|
||||
user, client_id, client_name, client_icon,
|
||||
token_type, access_token_expiration)
|
||||
|
||||
async def async_get_refresh_token(
|
||||
self, token_id: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by id."""
|
||||
return await self._store.async_get_refresh_token(token_id)
|
||||
|
||||
async def async_get_refresh_token_by_token(
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by token."""
|
||||
return await self._store.async_get_refresh_token_by_token(token)
|
||||
|
||||
async def async_remove_refresh_token(self,
|
||||
refresh_token: models.RefreshToken) \
|
||||
-> None:
|
||||
"""Delete a refresh token."""
|
||||
await self._store.async_remove_refresh_token(refresh_token)
|
||||
|
||||
@callback
|
||||
def async_create_access_token(self,
|
||||
refresh_token: models.RefreshToken,
|
||||
remote_ip: Optional[str] = None) -> str:
|
||||
"""Create a new access token."""
|
||||
self._store.async_log_refresh_token_usage(refresh_token, remote_ip)
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
now = dt_util.utcnow()
|
||||
return jwt.encode({
|
||||
'iss': refresh_token.id,
|
||||
'iat': now,
|
||||
'exp': now + refresh_token.access_token_expiration,
|
||||
}, refresh_token.jwt_key, algorithm='HS256').decode()
|
||||
|
||||
async def async_validate_access_token(
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Return refresh token if an access token is valid."""
|
||||
try:
|
||||
unverif_claims = jwt.decode(token, verify=False)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
refresh_token = await self.async_get_refresh_token(
|
||||
cast(str, unverif_claims.get('iss')))
|
||||
|
||||
if refresh_token is None:
|
||||
jwt_key = ''
|
||||
issuer = ''
|
||||
else:
|
||||
jwt_key = refresh_token.jwt_key
|
||||
issuer = refresh_token.id
|
||||
|
||||
try:
|
||||
jwt.decode(
|
||||
token,
|
||||
jwt_key,
|
||||
leeway=10,
|
||||
issuer=issuer,
|
||||
algorithms=['HS256']
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
if refresh_token is None or not refresh_token.user.is_active:
|
||||
return None
|
||||
|
||||
return refresh_token
|
||||
|
||||
async def _async_create_login_flow(
|
||||
self, handler: _ProviderKey, *, context: Optional[Dict],
|
||||
data: Optional[Any]) -> data_entry_flow.FlowHandler:
|
||||
"""Create a login flow."""
|
||||
auth_provider = self._providers[handler]
|
||||
|
||||
return await auth_provider.async_login_flow(context)
|
||||
|
||||
async def _async_finish_login_flow(
|
||||
self, flow: LoginFlow, result: Dict[str, Any]) \
|
||||
-> Dict[str, Any]:
|
||||
"""Return a user as result of login flow."""
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
return result
|
||||
|
||||
# we got final result
|
||||
if isinstance(result['data'], models.User):
|
||||
result['result'] = result['data']
|
||||
return result
|
||||
|
||||
auth_provider = self._providers[result['handler']]
|
||||
credentials = await auth_provider.async_get_or_create_credentials(
|
||||
result['data'])
|
||||
|
||||
if flow.context is not None and flow.context.get('credential_only'):
|
||||
result['result'] = credentials
|
||||
return result
|
||||
|
||||
# multi-factor module cannot enabled for new credential
|
||||
# which has not linked to a user yet
|
||||
if auth_provider.support_mfa and not credentials.is_new:
|
||||
user = await self.async_get_user_by_credentials(credentials)
|
||||
if user is not None:
|
||||
modules = await self.async_get_enabled_mfa(user)
|
||||
|
||||
if modules:
|
||||
flow.user = user
|
||||
flow.available_mfa_modules = modules
|
||||
return await flow.async_step_select_mfa_module()
|
||||
|
||||
result['result'] = await self.async_get_or_create_user(credentials)
|
||||
return result
|
||||
|
||||
@callback
|
||||
def _async_get_auth_provider(
|
||||
self, credentials: models.Credentials) -> Optional[AuthProvider]:
|
||||
"""Get auth provider from a set of credentials."""
|
||||
auth_provider_key = (credentials.auth_provider_type,
|
||||
credentials.auth_provider_id)
|
||||
return self._providers.get(auth_provider_key)
|
||||
|
||||
async def _user_should_be_owner(self) -> bool:
|
||||
"""Determine if user should be owner.
|
||||
|
||||
A user should be an owner if it is the first non-system user that is
|
||||
being created.
|
||||
"""
|
||||
for user in await self._store.async_get_users():
|
||||
if not user.system_generated:
|
||||
return False
|
||||
|
||||
return True
|
||||
342
homeassistant/auth/auth_store.py
Normal file
342
homeassistant/auth/auth_store.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""Storage for auth models."""
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, List, Optional # noqa: F401
|
||||
import hmac
|
||||
|
||||
from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import models
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = 'auth'
|
||||
|
||||
|
||||
class AuthStore:
|
||||
"""Stores authentication info.
|
||||
|
||||
Any mutation to an object should happen inside the auth store.
|
||||
|
||||
The auth store is lazy. It won't load the data from disk until a method is
|
||||
called that needs it.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the auth store."""
|
||||
self.hass = hass
|
||||
self._users = None # type: Optional[Dict[str, models.User]]
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
|
||||
async def async_get_users(self) -> List[models.User]:
|
||||
"""Retrieve all users."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
return list(self._users.values())
|
||||
|
||||
async def async_get_user(self, user_id: str) -> Optional[models.User]:
|
||||
"""Retrieve a user by id."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
return self._users.get(user_id)
|
||||
|
||||
async def async_create_user(
|
||||
self, name: Optional[str], is_owner: Optional[bool] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
system_generated: Optional[bool] = None,
|
||||
credentials: Optional[models.Credentials] = None) -> models.User:
|
||||
"""Create a new user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
kwargs = {
|
||||
'name': name
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if is_owner is not None:
|
||||
kwargs['is_owner'] = is_owner
|
||||
|
||||
if is_active is not None:
|
||||
kwargs['is_active'] = is_active
|
||||
|
||||
if system_generated is not None:
|
||||
kwargs['system_generated'] = system_generated
|
||||
|
||||
new_user = models.User(**kwargs)
|
||||
|
||||
self._users[new_user.id] = new_user
|
||||
|
||||
if credentials is None:
|
||||
self._async_schedule_save()
|
||||
return new_user
|
||||
|
||||
# Saving is done inside the link.
|
||||
await self.async_link_user(new_user, credentials)
|
||||
return new_user
|
||||
|
||||
async def async_link_user(self, user: models.User,
|
||||
credentials: models.Credentials) -> None:
|
||||
"""Add credentials to an existing user."""
|
||||
user.credentials.append(credentials)
|
||||
self._async_schedule_save()
|
||||
credentials.is_new = False
|
||||
|
||||
async def async_remove_user(self, user: models.User) -> None:
|
||||
"""Remove a user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
self._users.pop(user.id)
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_activate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
user.is_active = True
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_deactivate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
user.is_active = False
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_remove_credentials(
|
||||
self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
found = None
|
||||
|
||||
for index, cred in enumerate(user.credentials):
|
||||
if cred is credentials:
|
||||
found = index
|
||||
break
|
||||
|
||||
if found is not None:
|
||||
user.credentials.pop(found)
|
||||
break
|
||||
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_create_refresh_token(
|
||||
self, user: models.User, client_id: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
client_icon: Optional[str] = None,
|
||||
token_type: str = models.TOKEN_TYPE_NORMAL,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \
|
||||
-> models.RefreshToken:
|
||||
"""Create a new token for a user."""
|
||||
kwargs = {
|
||||
'user': user,
|
||||
'client_id': client_id,
|
||||
'token_type': token_type,
|
||||
'access_token_expiration': access_token_expiration
|
||||
} # type: Dict[str, Any]
|
||||
if client_name:
|
||||
kwargs['client_name'] = client_name
|
||||
if client_icon:
|
||||
kwargs['client_icon'] = client_icon
|
||||
|
||||
refresh_token = models.RefreshToken(**kwargs)
|
||||
user.refresh_tokens[refresh_token.id] = refresh_token
|
||||
|
||||
self._async_schedule_save()
|
||||
return refresh_token
|
||||
|
||||
async def async_remove_refresh_token(
|
||||
self, refresh_token: models.RefreshToken) -> None:
|
||||
"""Remove a refresh token."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
if user.refresh_tokens.pop(refresh_token.id, None):
|
||||
self._async_schedule_save()
|
||||
break
|
||||
|
||||
async def async_get_refresh_token(
|
||||
self, token_id: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by id."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
refresh_token = user.refresh_tokens.get(token_id)
|
||||
if refresh_token is not None:
|
||||
return refresh_token
|
||||
|
||||
return None
|
||||
|
||||
async def async_get_refresh_token_by_token(
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by token."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
found = None
|
||||
|
||||
for user in self._users.values():
|
||||
for refresh_token in user.refresh_tokens.values():
|
||||
if hmac.compare_digest(refresh_token.token, token):
|
||||
found = refresh_token
|
||||
|
||||
return found
|
||||
|
||||
@callback
|
||||
def async_log_refresh_token_usage(
|
||||
self, refresh_token: models.RefreshToken,
|
||||
remote_ip: Optional[str] = None) -> None:
|
||||
"""Update refresh token last used information."""
|
||||
refresh_token.last_used_at = dt_util.utcnow()
|
||||
refresh_token.last_used_ip = remote_ip
|
||||
self._async_schedule_save()
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
"""Load the users."""
|
||||
data = await 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
|
||||
|
||||
users = OrderedDict() # type: Dict[str, models.User]
|
||||
|
||||
if data is None:
|
||||
self._users = users
|
||||
return
|
||||
|
||||
for user_dict in data['users']:
|
||||
users[user_dict['id']] = models.User(**user_dict)
|
||||
|
||||
for cred_dict in data['credentials']:
|
||||
users[cred_dict['user_id']].credentials.append(models.Credentials(
|
||||
id=cred_dict['id'],
|
||||
is_new=False,
|
||||
auth_provider_type=cred_dict['auth_provider_type'],
|
||||
auth_provider_id=cred_dict['auth_provider_id'],
|
||||
data=cred_dict['data'],
|
||||
))
|
||||
|
||||
for rt_dict in data['refresh_tokens']:
|
||||
# Filter out the old keys that don't have jwt_key (pre-0.76)
|
||||
if 'jwt_key' not in rt_dict:
|
||||
continue
|
||||
|
||||
created_at = dt_util.parse_datetime(rt_dict['created_at'])
|
||||
if created_at is None:
|
||||
getLogger(__name__).error(
|
||||
'Ignoring refresh token %(id)s with invalid created_at '
|
||||
'%(created_at)s for user_id %(user_id)s', rt_dict)
|
||||
continue
|
||||
|
||||
token_type = rt_dict.get('token_type')
|
||||
if token_type is None:
|
||||
if rt_dict['client_id'] is None:
|
||||
token_type = models.TOKEN_TYPE_SYSTEM
|
||||
else:
|
||||
token_type = models.TOKEN_TYPE_NORMAL
|
||||
|
||||
# old refresh_token don't have last_used_at (pre-0.78)
|
||||
last_used_at_str = rt_dict.get('last_used_at')
|
||||
if last_used_at_str:
|
||||
last_used_at = dt_util.parse_datetime(last_used_at_str)
|
||||
else:
|
||||
last_used_at = None
|
||||
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict['id'],
|
||||
user=users[rt_dict['user_id']],
|
||||
client_id=rt_dict['client_id'],
|
||||
# use dict.get to keep backward compatibility
|
||||
client_name=rt_dict.get('client_name'),
|
||||
client_icon=rt_dict.get('client_icon'),
|
||||
token_type=token_type,
|
||||
created_at=created_at,
|
||||
access_token_expiration=timedelta(
|
||||
seconds=rt_dict['access_token_expiration']),
|
||||
token=rt_dict['token'],
|
||||
jwt_key=rt_dict['jwt_key'],
|
||||
last_used_at=last_used_at,
|
||||
last_used_ip=rt_dict.get('last_used_ip'),
|
||||
)
|
||||
users[rt_dict['user_id']].refresh_tokens[token.id] = token
|
||||
|
||||
self._users = users
|
||||
|
||||
@callback
|
||||
def _async_schedule_save(self) -> None:
|
||||
"""Save users."""
|
||||
if self._users is None:
|
||||
return
|
||||
|
||||
self._store.async_delay_save(self._data_to_save, 1)
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> Dict:
|
||||
"""Return the data to store."""
|
||||
assert self._users is not None
|
||||
|
||||
users = [
|
||||
{
|
||||
'id': user.id,
|
||||
'is_owner': user.is_owner,
|
||||
'is_active': user.is_active,
|
||||
'name': user.name,
|
||||
'system_generated': user.system_generated,
|
||||
}
|
||||
for user in self._users.values()
|
||||
]
|
||||
|
||||
credentials = [
|
||||
{
|
||||
'id': credential.id,
|
||||
'user_id': user.id,
|
||||
'auth_provider_type': credential.auth_provider_type,
|
||||
'auth_provider_id': credential.auth_provider_id,
|
||||
'data': credential.data,
|
||||
}
|
||||
for user in self._users.values()
|
||||
for credential in user.credentials
|
||||
]
|
||||
|
||||
refresh_tokens = [
|
||||
{
|
||||
'id': refresh_token.id,
|
||||
'user_id': user.id,
|
||||
'client_id': refresh_token.client_id,
|
||||
'client_name': refresh_token.client_name,
|
||||
'client_icon': refresh_token.client_icon,
|
||||
'token_type': refresh_token.token_type,
|
||||
'created_at': refresh_token.created_at.isoformat(),
|
||||
'access_token_expiration':
|
||||
refresh_token.access_token_expiration.total_seconds(),
|
||||
'token': refresh_token.token,
|
||||
'jwt_key': refresh_token.jwt_key,
|
||||
'last_used_at':
|
||||
refresh_token.last_used_at.isoformat()
|
||||
if refresh_token.last_used_at else None,
|
||||
'last_used_ip': refresh_token.last_used_ip,
|
||||
}
|
||||
for user in self._users.values()
|
||||
for refresh_token in user.refresh_tokens.values()
|
||||
]
|
||||
|
||||
return {
|
||||
'users': users,
|
||||
'credentials': credentials,
|
||||
'refresh_tokens': refresh_tokens,
|
||||
}
|
||||
5
homeassistant/auth/const.py
Normal file
5
homeassistant/auth/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the auth module."""
|
||||
from datetime import timedelta
|
||||
|
||||
ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
|
||||
MFA_SESSION_EXPIRATION = timedelta(minutes=5)
|
||||
175
homeassistant/auth/mfa_modules/__init__.py
Normal file
175
homeassistant/auth/mfa_modules/__init__.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Plugable auth modules for Home Assistant."""
|
||||
import importlib
|
||||
import logging
|
||||
import types
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant import requirements, data_entry_flow
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
MULTI_FACTOR_AUTH_MODULES = Registry()
|
||||
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Optional(CONF_NAME): str,
|
||||
# Specify ID if you have two mfa auth module for same type.
|
||||
vol.Optional(CONF_ID): str,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
DATA_REQS = 'mfa_auth_module_reqs_processed'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MultiFactorAuthModule:
|
||||
"""Multi-factor Auth Module of validation function."""
|
||||
|
||||
DEFAULT_TITLE = 'Unnamed auth module'
|
||||
MAX_RETRY_TIME = 3
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize an auth module."""
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def id(self) -> str: # pylint: disable=invalid-name
|
||||
"""Return id of the auth module.
|
||||
|
||||
Default is same as type
|
||||
"""
|
||||
return self.config.get(CONF_ID, self.type)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""Return type of the module."""
|
||||
return self.config[CONF_TYPE] # type: ignore
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the auth module."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
|
||||
# Implement by extending class
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Return a voluptuous schema to define mfa auth module's input."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_setup_flow(self, user_id: str) -> 'SetupFlow':
|
||||
"""Return a data entry flow handler for setup module.
|
||||
|
||||
Mfa module should extend SetupFlow
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
|
||||
"""Set up user for mfa auth module."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Remove user from mfa module."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_validate(
|
||||
self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SetupFlow(data_entry_flow.FlowHandler):
|
||||
"""Handler for the setup flow."""
|
||||
|
||||
def __init__(self, auth_module: MultiFactorAuthModule,
|
||||
setup_schema: vol.Schema,
|
||||
user_id: str) -> None:
|
||||
"""Initialize the setup flow."""
|
||||
self._auth_module = auth_module
|
||||
self._setup_schema = setup_schema
|
||||
self._user_id = user_id
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the first step of setup flow.
|
||||
|
||||
Return self.async_show_form(step_id='init') if user_input == None.
|
||||
Return self.async_create_entry(data={'result': result}) if finish.
|
||||
"""
|
||||
errors = {} # type: Dict[str, str]
|
||||
|
||||
if user_input:
|
||||
result = await self._auth_module.async_setup_user(
|
||||
self._user_id, user_input)
|
||||
return self.async_create_entry(
|
||||
title=self._auth_module.name,
|
||||
data={'result': result}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=self._setup_schema,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
async def auth_mfa_module_from_config(
|
||||
hass: HomeAssistant, config: Dict[str, Any]) \
|
||||
-> MultiFactorAuthModule:
|
||||
"""Initialize an auth module from a config."""
|
||||
module_name = config[CONF_TYPE]
|
||||
module = await _load_mfa_module(hass, module_name)
|
||||
|
||||
try:
|
||||
config = module.CONFIG_SCHEMA(config) # type: ignore
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Invalid configuration for multi-factor module %s: %s',
|
||||
module_name, humanize_error(config, err))
|
||||
raise
|
||||
|
||||
return MULTI_FACTOR_AUTH_MODULES[module_name](hass, config) # type: ignore
|
||||
|
||||
|
||||
async def _load_mfa_module(hass: HomeAssistant, module_name: str) \
|
||||
-> types.ModuleType:
|
||||
"""Load an mfa auth module."""
|
||||
module_path = 'homeassistant.auth.mfa_modules.{}'.format(module_name)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError as err:
|
||||
_LOGGER.error('Unable to load mfa module %s: %s', module_name, err)
|
||||
raise HomeAssistantError('Unable to load mfa module {}: {}'.format(
|
||||
module_name, err))
|
||||
|
||||
if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'):
|
||||
return module
|
||||
|
||||
processed = hass.data.get(DATA_REQS)
|
||||
if processed and module_name in processed:
|
||||
return module
|
||||
|
||||
processed = hass.data[DATA_REQS] = set()
|
||||
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
req_success = await requirements.async_process_requirements(
|
||||
hass, module_path, module.REQUIREMENTS) # type: ignore
|
||||
|
||||
if not req_success:
|
||||
raise HomeAssistantError(
|
||||
'Unable to process requirements of mfa module {}'.format(
|
||||
module_name))
|
||||
|
||||
processed.add(module_name)
|
||||
return module
|
||||
89
homeassistant/auth/mfa_modules/insecure_example.py
Normal file
89
homeassistant/auth/mfa_modules/insecure_example.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Example auth module."""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow
|
||||
|
||||
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({
|
||||
vol.Required('data'): [vol.Schema({
|
||||
vol.Required('user_id'): str,
|
||||
vol.Required('pin'): str,
|
||||
})]
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@MULTI_FACTOR_AUTH_MODULES.register('insecure_example')
|
||||
class InsecureExampleModule(MultiFactorAuthModule):
|
||||
"""Example auth module validate pin."""
|
||||
|
||||
DEFAULT_TITLE = 'Insecure Personal Identify Number'
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize the user data store."""
|
||||
super().__init__(hass, config)
|
||||
self._data = config['data']
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Validate login flow input data."""
|
||||
return vol.Schema({'pin': str})
|
||||
|
||||
@property
|
||||
def setup_schema(self) -> vol.Schema:
|
||||
"""Validate async_setup_user input data."""
|
||||
return vol.Schema({'pin': str})
|
||||
|
||||
async def async_setup_flow(self, user_id: str) -> SetupFlow:
|
||||
"""Return a data entry flow handler for setup module.
|
||||
|
||||
Mfa module should extend SetupFlow
|
||||
"""
|
||||
return SetupFlow(self, self.setup_schema, user_id)
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
|
||||
"""Set up user to use mfa module."""
|
||||
# data shall has been validate in caller
|
||||
pin = setup_data['pin']
|
||||
|
||||
for data in self._data:
|
||||
if data['user_id'] == user_id:
|
||||
# already setup, override
|
||||
data['pin'] = pin
|
||||
return
|
||||
|
||||
self._data.append({'user_id': user_id, 'pin': pin})
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Remove user from mfa module."""
|
||||
found = None
|
||||
for data in self._data:
|
||||
if data['user_id'] == user_id:
|
||||
found = data
|
||||
break
|
||||
if found:
|
||||
self._data.remove(found)
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
for data in self._data:
|
||||
if data['user_id'] == user_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_validate(
|
||||
self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
for data in self._data:
|
||||
if data['user_id'] == user_id:
|
||||
# user_input has been validate in caller
|
||||
if data['pin'] == user_input['pin']:
|
||||
return True
|
||||
|
||||
return False
|
||||
325
homeassistant/auth/mfa_modules/notify.py
Normal file
325
homeassistant/auth/mfa_modules/notify.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""HMAC-based One-time Password auth module.
|
||||
|
||||
Sending HOTP through notify service
|
||||
"""
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, Optional, Tuple, List # noqa: F401
|
||||
|
||||
import attr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow
|
||||
|
||||
REQUIREMENTS = ['pyotp==2.2.6']
|
||||
|
||||
CONF_MESSAGE = 'message'
|
||||
|
||||
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({
|
||||
vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_MESSAGE,
|
||||
default='{} is your Home Assistant login code'): str
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = 'auth_module.notify'
|
||||
STORAGE_USERS = 'users'
|
||||
STORAGE_USER_ID = 'user_id'
|
||||
|
||||
INPUT_FIELD_CODE = 'code'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_secret() -> str:
|
||||
"""Generate a secret."""
|
||||
import pyotp
|
||||
return str(pyotp.random_base32())
|
||||
|
||||
|
||||
def _generate_random() -> int:
|
||||
"""Generate a 8 digit number."""
|
||||
import pyotp
|
||||
return int(pyotp.random_base32(length=8, chars=list('1234567890')))
|
||||
|
||||
|
||||
def _generate_otp(secret: str, count: int) -> str:
|
||||
"""Generate one time password."""
|
||||
import pyotp
|
||||
return str(pyotp.HOTP(secret).at(count))
|
||||
|
||||
|
||||
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
||||
"""Verify one time password."""
|
||||
import pyotp
|
||||
return bool(pyotp.HOTP(secret).verify(otp, count))
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class NotifySetting:
|
||||
"""Store notify setting for one user."""
|
||||
|
||||
secret = attr.ib(type=str, factory=_generate_secret) # not persistent
|
||||
counter = attr.ib(type=int, factory=_generate_random) # not persistent
|
||||
notify_service = attr.ib(type=Optional[str], default=None)
|
||||
target = attr.ib(type=Optional[str], default=None)
|
||||
|
||||
|
||||
_UsersDict = Dict[str, NotifySetting]
|
||||
|
||||
|
||||
@MULTI_FACTOR_AUTH_MODULES.register('notify')
|
||||
class NotifyAuthModule(MultiFactorAuthModule):
|
||||
"""Auth module send hmac-based one time password by notify service."""
|
||||
|
||||
DEFAULT_TITLE = 'Notify One-Time Password'
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize the user data store."""
|
||||
super().__init__(hass, config)
|
||||
self._user_settings = None # type: Optional[_UsersDict]
|
||||
self._user_store = hass.helpers.storage.Store(
|
||||
STORAGE_VERSION, STORAGE_KEY)
|
||||
self._include = config.get(CONF_INCLUDE, [])
|
||||
self._exclude = config.get(CONF_EXCLUDE, [])
|
||||
self._message_template = config[CONF_MESSAGE]
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Validate login flow input data."""
|
||||
return vol.Schema({INPUT_FIELD_CODE: str})
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
data = await self._user_store.async_load()
|
||||
|
||||
if data is None:
|
||||
data = {STORAGE_USERS: {}}
|
||||
|
||||
self._user_settings = {
|
||||
user_id: NotifySetting(**setting)
|
||||
for user_id, setting in data.get(STORAGE_USERS, {}).items()
|
||||
}
|
||||
|
||||
async def _async_save(self) -> None:
|
||||
"""Save data."""
|
||||
if self._user_settings is None:
|
||||
return
|
||||
|
||||
await self._user_store.async_save({STORAGE_USERS: {
|
||||
user_id: attr.asdict(
|
||||
notify_setting, filter=attr.filters.exclude(
|
||||
attr.fields(NotifySetting).secret,
|
||||
attr.fields(NotifySetting).counter,
|
||||
))
|
||||
for user_id, notify_setting
|
||||
in self._user_settings.items()
|
||||
}})
|
||||
|
||||
@callback
|
||||
def aync_get_available_notify_services(self) -> List[str]:
|
||||
"""Return list of notify services."""
|
||||
unordered_services = set()
|
||||
|
||||
for service in self.hass.services.async_services().get('notify', {}):
|
||||
if service not in self._exclude:
|
||||
unordered_services.add(service)
|
||||
|
||||
if self._include:
|
||||
unordered_services &= set(self._include)
|
||||
|
||||
return sorted(unordered_services)
|
||||
|
||||
async def async_setup_flow(self, user_id: str) -> SetupFlow:
|
||||
"""Return a data entry flow handler for setup module.
|
||||
|
||||
Mfa module should extend SetupFlow
|
||||
"""
|
||||
return NotifySetupFlow(
|
||||
self, self.input_schema, user_id,
|
||||
self.aync_get_available_notify_services())
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
|
||||
"""Set up auth module for user."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
self._user_settings[user_id] = NotifySetting(
|
||||
notify_service=setup_data.get('notify_service'),
|
||||
target=setup_data.get('target'),
|
||||
)
|
||||
|
||||
await self._async_save()
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Depose auth module for user."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
if self._user_settings.pop(user_id, None):
|
||||
await self._async_save()
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
return user_id in self._user_settings
|
||||
|
||||
async def async_validate(
|
||||
self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
notify_setting = self._user_settings.get(user_id, None)
|
||||
if notify_setting is None:
|
||||
return False
|
||||
|
||||
# user_input has been validate in caller
|
||||
return await self.hass.async_add_executor_job(
|
||||
_verify_otp, notify_setting.secret,
|
||||
user_input.get(INPUT_FIELD_CODE, ''),
|
||||
notify_setting.counter)
|
||||
|
||||
async def async_initialize_login_mfa_step(self, user_id: str) -> None:
|
||||
"""Generate code and notify user."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
notify_setting = self._user_settings.get(user_id, None)
|
||||
if notify_setting is None:
|
||||
raise ValueError('Cannot find user_id')
|
||||
|
||||
def generate_secret_and_one_time_password() -> str:
|
||||
"""Generate and send one time password."""
|
||||
assert notify_setting
|
||||
# secret and counter are not persistent
|
||||
notify_setting.secret = _generate_secret()
|
||||
notify_setting.counter = _generate_random()
|
||||
return _generate_otp(
|
||||
notify_setting.secret, notify_setting.counter)
|
||||
|
||||
code = await self.hass.async_add_executor_job(
|
||||
generate_secret_and_one_time_password)
|
||||
|
||||
await self.async_notify_user(user_id, code)
|
||||
|
||||
async def async_notify_user(self, user_id: str, code: str) -> None:
|
||||
"""Send code by user's notify service."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
notify_setting = self._user_settings.get(user_id, None)
|
||||
if notify_setting is None:
|
||||
_LOGGER.error('Cannot find user %s', user_id)
|
||||
return
|
||||
|
||||
await self.async_notify( # type: ignore
|
||||
code, notify_setting.notify_service, notify_setting.target)
|
||||
|
||||
async def async_notify(self, code: str, notify_service: str,
|
||||
target: Optional[str] = None) -> None:
|
||||
"""Send code by notify service."""
|
||||
data = {'message': self._message_template.format(code)}
|
||||
if target:
|
||||
data['target'] = [target]
|
||||
|
||||
await self.hass.services.async_call('notify', notify_service, data)
|
||||
|
||||
|
||||
class NotifySetupFlow(SetupFlow):
|
||||
"""Handler for the setup flow."""
|
||||
|
||||
def __init__(self, auth_module: NotifyAuthModule,
|
||||
setup_schema: vol.Schema,
|
||||
user_id: str,
|
||||
available_notify_services: List[str]) -> None:
|
||||
"""Initialize the setup flow."""
|
||||
super().__init__(auth_module, setup_schema, user_id)
|
||||
# to fix typing complaint
|
||||
self._auth_module = auth_module # type: NotifyAuthModule
|
||||
self._available_notify_services = available_notify_services
|
||||
self._secret = None # type: Optional[str]
|
||||
self._count = None # type: Optional[int]
|
||||
self._notify_service = None # type: Optional[str]
|
||||
self._target = None # type: Optional[str]
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Let user select available notify services."""
|
||||
errors = {} # type: Dict[str, str]
|
||||
|
||||
hass = self._auth_module.hass
|
||||
if user_input:
|
||||
self._notify_service = user_input['notify_service']
|
||||
self._target = user_input.get('target')
|
||||
self._secret = await hass.async_add_executor_job(_generate_secret)
|
||||
self._count = await hass.async_add_executor_job(_generate_random)
|
||||
|
||||
return await self.async_step_setup()
|
||||
|
||||
if not self._available_notify_services:
|
||||
return self.async_abort(reason='no_available_service')
|
||||
|
||||
schema = OrderedDict() # type: Dict[str, Any]
|
||||
schema['notify_service'] = vol.In(self._available_notify_services)
|
||||
schema['target'] = vol.Optional(str)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors
|
||||
)
|
||||
|
||||
async def async_step_setup(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Verify user can recevie one-time password."""
|
||||
errors = {} # type: Dict[str, str]
|
||||
|
||||
hass = self._auth_module.hass
|
||||
if user_input:
|
||||
verified = await hass.async_add_executor_job(
|
||||
_verify_otp, self._secret, user_input['code'], self._count)
|
||||
if verified:
|
||||
await self._auth_module.async_setup_user(
|
||||
self._user_id, {
|
||||
'notify_service': self._notify_service,
|
||||
'target': self._target,
|
||||
})
|
||||
return self.async_create_entry(
|
||||
title=self._auth_module.name,
|
||||
data={}
|
||||
)
|
||||
|
||||
errors['base'] = 'invalid_code'
|
||||
|
||||
# generate code every time, no retry logic
|
||||
assert self._secret and self._count
|
||||
code = await hass.async_add_executor_job(
|
||||
_generate_otp, self._secret, self._count)
|
||||
|
||||
assert self._notify_service
|
||||
await self._auth_module.async_notify(
|
||||
code, self._notify_service, self._target)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='setup',
|
||||
data_schema=self._setup_schema,
|
||||
description_placeholders={'notify_service': self._notify_service},
|
||||
errors=errors,
|
||||
)
|
||||
214
homeassistant/auth/mfa_modules/totp.py
Normal file
214
homeassistant/auth/mfa_modules/totp.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Time-based One Time Password auth module."""
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, Optional, Tuple # noqa: F401
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow
|
||||
|
||||
REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1']
|
||||
|
||||
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = 'auth_module.totp'
|
||||
STORAGE_USERS = 'users'
|
||||
STORAGE_USER_ID = 'user_id'
|
||||
STORAGE_OTA_SECRET = 'ota_secret'
|
||||
|
||||
INPUT_FIELD_CODE = 'code'
|
||||
|
||||
DUMMY_SECRET = 'FPPTH34D4E3MI2HG'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_qr_code(data: str) -> str:
|
||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||
import pyqrcode
|
||||
|
||||
qr_code = pyqrcode.create(data)
|
||||
|
||||
with BytesIO() as buffer:
|
||||
qr_code.svg(file=buffer, scale=4)
|
||||
return '{}'.format(
|
||||
buffer.getvalue().decode("ascii").replace('\n', '')
|
||||
.replace('<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"', '<svg')
|
||||
)
|
||||
|
||||
|
||||
def _generate_secret_and_qr_code(username: str) -> Tuple[str, str, str]:
|
||||
"""Generate a secret, url, and QR code."""
|
||||
import pyotp
|
||||
|
||||
ota_secret = pyotp.random_base32()
|
||||
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
||||
username, issuer_name="Home Assistant")
|
||||
image = _generate_qr_code(url)
|
||||
return ota_secret, url, image
|
||||
|
||||
|
||||
@MULTI_FACTOR_AUTH_MODULES.register('totp')
|
||||
class TotpAuthModule(MultiFactorAuthModule):
|
||||
"""Auth module validate time-based one time password."""
|
||||
|
||||
DEFAULT_TITLE = 'Time-based One Time Password'
|
||||
MAX_RETRY_TIME = 5
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize the user data store."""
|
||||
super().__init__(hass, config)
|
||||
self._users = None # type: Optional[Dict[str, str]]
|
||||
self._user_store = hass.helpers.storage.Store(
|
||||
STORAGE_VERSION, STORAGE_KEY)
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Validate login flow input data."""
|
||||
return vol.Schema({INPUT_FIELD_CODE: str})
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
data = await self._user_store.async_load()
|
||||
|
||||
if data is None:
|
||||
data = {STORAGE_USERS: {}}
|
||||
|
||||
self._users = data.get(STORAGE_USERS, {})
|
||||
|
||||
async def _async_save(self) -> None:
|
||||
"""Save data."""
|
||||
await self._user_store.async_save({STORAGE_USERS: self._users})
|
||||
|
||||
def _add_ota_secret(self, user_id: str,
|
||||
secret: Optional[str] = None) -> str:
|
||||
"""Create a ota_secret for user."""
|
||||
import pyotp
|
||||
|
||||
ota_secret = secret or pyotp.random_base32() # type: str
|
||||
|
||||
self._users[user_id] = ota_secret # type: ignore
|
||||
return ota_secret
|
||||
|
||||
async def async_setup_flow(self, user_id: str) -> SetupFlow:
|
||||
"""Return a data entry flow handler for setup module.
|
||||
|
||||
Mfa module should extend SetupFlow
|
||||
"""
|
||||
user = await self.hass.auth.async_get_user(user_id) # type: ignore
|
||||
return TotpSetupFlow(self, self.input_schema, user)
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> str:
|
||||
"""Set up auth module for user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
result = await self.hass.async_add_executor_job(
|
||||
self._add_ota_secret, user_id, setup_data.get('secret'))
|
||||
|
||||
await self._async_save()
|
||||
return result
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Depose auth module for user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
if self._users.pop(user_id, None): # type: ignore
|
||||
await self._async_save()
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
return user_id in self._users # type: ignore
|
||||
|
||||
async def async_validate(
|
||||
self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
# user_input has been validate in caller
|
||||
# set INPUT_FIELD_CODE as vol.Required is not user friendly
|
||||
return await self.hass.async_add_executor_job(
|
||||
self._validate_2fa, user_id, user_input.get(INPUT_FIELD_CODE, ''))
|
||||
|
||||
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
||||
"""Validate two factor authentication code."""
|
||||
import pyotp
|
||||
|
||||
ota_secret = self._users.get(user_id) # type: ignore
|
||||
if ota_secret is None:
|
||||
# even we cannot find user, we still do verify
|
||||
# to make timing the same as if user was found.
|
||||
pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1)
|
||||
return False
|
||||
|
||||
return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1))
|
||||
|
||||
|
||||
class TotpSetupFlow(SetupFlow):
|
||||
"""Handler for the setup flow."""
|
||||
|
||||
def __init__(self, auth_module: TotpAuthModule,
|
||||
setup_schema: vol.Schema,
|
||||
user: User) -> None:
|
||||
"""Initialize the setup flow."""
|
||||
super().__init__(auth_module, setup_schema, user.id)
|
||||
# to fix typing complaint
|
||||
self._auth_module = auth_module # type: TotpAuthModule
|
||||
self._user = user
|
||||
self._ota_secret = None # type: Optional[str]
|
||||
self._url = None # type Optional[str]
|
||||
self._image = None # type Optional[str]
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the first step of setup flow.
|
||||
|
||||
Return self.async_show_form(step_id='init') if user_input == None.
|
||||
Return self.async_create_entry(data={'result': result}) if finish.
|
||||
"""
|
||||
import pyotp
|
||||
|
||||
errors = {} # type: Dict[str, str]
|
||||
|
||||
if user_input:
|
||||
verified = await self.hass.async_add_executor_job( # type: ignore
|
||||
pyotp.TOTP(self._ota_secret).verify, user_input['code'])
|
||||
if verified:
|
||||
result = await self._auth_module.async_setup_user(
|
||||
self._user_id, {'secret': self._ota_secret})
|
||||
return self.async_create_entry(
|
||||
title=self._auth_module.name,
|
||||
data={'result': result}
|
||||
)
|
||||
|
||||
errors['base'] = 'invalid_code'
|
||||
|
||||
else:
|
||||
hass = self._auth_module.hass
|
||||
self._ota_secret, self._url, self._image = \
|
||||
await hass.async_add_executor_job( # type: ignore
|
||||
_generate_secret_and_qr_code, str(self._user.name))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=self._setup_schema,
|
||||
description_placeholders={
|
||||
'code': self._ota_secret,
|
||||
'url': self._url,
|
||||
'qr_code': self._image
|
||||
},
|
||||
errors=errors
|
||||
)
|
||||
77
homeassistant/auth/models.py
Normal file
77
homeassistant/auth/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Auth models."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, NamedTuple, Optional # noqa: F401
|
||||
import uuid
|
||||
|
||||
import attr
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .util import generate_secret
|
||||
|
||||
TOKEN_TYPE_NORMAL = 'normal'
|
||||
TOKEN_TYPE_SYSTEM = 'system'
|
||||
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token'
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class User:
|
||||
"""A user."""
|
||||
|
||||
name = attr.ib(type=str) # type: Optional[str]
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_owner = attr.ib(type=bool, default=False)
|
||||
is_active = attr.ib(type=bool, default=False)
|
||||
system_generated = attr.ib(type=bool, default=False)
|
||||
|
||||
# List of credentials of a user.
|
||||
credentials = attr.ib(
|
||||
type=list, default=attr.Factory(list), cmp=False
|
||||
) # type: List[Credentials]
|
||||
|
||||
# Tokens associated with a user.
|
||||
refresh_tokens = attr.ib(
|
||||
type=dict, default=attr.Factory(dict), cmp=False
|
||||
) # type: Dict[str, RefreshToken]
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class RefreshToken:
|
||||
"""RefreshToken for a user to grant new access tokens."""
|
||||
|
||||
user = attr.ib(type=User)
|
||||
client_id = attr.ib(type=Optional[str])
|
||||
access_token_expiration = attr.ib(type=timedelta)
|
||||
client_name = attr.ib(type=Optional[str], default=None)
|
||||
client_icon = attr.ib(type=Optional[str], default=None)
|
||||
token_type = attr.ib(type=str, default=TOKEN_TYPE_NORMAL,
|
||||
validator=attr.validators.in_((
|
||||
TOKEN_TYPE_NORMAL, TOKEN_TYPE_SYSTEM,
|
||||
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN)))
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow))
|
||||
token = attr.ib(type=str,
|
||||
default=attr.Factory(lambda: generate_secret(64)))
|
||||
jwt_key = attr.ib(type=str,
|
||||
default=attr.Factory(lambda: generate_secret(64)))
|
||||
|
||||
last_used_at = attr.ib(type=Optional[datetime], default=None)
|
||||
last_used_ip = attr.ib(type=Optional[str], default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Credentials:
|
||||
"""Credentials for a user on an auth provider."""
|
||||
|
||||
auth_provider_type = attr.ib(type=str)
|
||||
auth_provider_id = attr.ib(type=Optional[str])
|
||||
|
||||
# Allow the auth provider to store data to represent their auth.
|
||||
data = attr.ib(type=dict)
|
||||
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_new = attr.ib(type=bool, default=True)
|
||||
|
||||
|
||||
UserMeta = NamedTuple("UserMeta",
|
||||
[('name', Optional[str]), ('is_active', bool)])
|
||||
268
homeassistant/auth/providers/__init__.py
Normal file
268
homeassistant/auth/providers/__init__.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Auth providers for Home Assistant."""
|
||||
import importlib
|
||||
import logging
|
||||
import types
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant import data_entry_flow, requirements
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from ..auth_store import AuthStore
|
||||
from ..const import MFA_SESSION_EXPIRATION
|
||||
from ..models import Credentials, User, UserMeta # noqa: F401
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
DATA_REQS = 'auth_prov_reqs_processed'
|
||||
|
||||
AUTH_PROVIDERS = Registry()
|
||||
|
||||
AUTH_PROVIDER_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Optional(CONF_NAME): str,
|
||||
# Specify ID if you have two auth providers for same type.
|
||||
vol.Optional(CONF_ID): str,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
class AuthProvider:
|
||||
"""Provider of user authentication."""
|
||||
|
||||
DEFAULT_TITLE = 'Unnamed auth provider'
|
||||
|
||||
def __init__(self, hass: HomeAssistant, store: AuthStore,
|
||||
config: Dict[str, Any]) -> None:
|
||||
"""Initialize an auth provider."""
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def id(self) -> Optional[str]: # pylint: disable=invalid-name
|
||||
"""Return id of the auth provider.
|
||||
|
||||
Optional, can be None.
|
||||
"""
|
||||
return self.config.get(CONF_ID)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""Return type of the provider."""
|
||||
return self.config[CONF_TYPE] # type: ignore
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the auth provider."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
|
||||
@property
|
||||
def support_mfa(self) -> bool:
|
||||
"""Return whether multi-factor auth supported by the auth provider."""
|
||||
return True
|
||||
|
||||
async def async_credentials(self) -> List[Credentials]:
|
||||
"""Return all credentials of this provider."""
|
||||
users = await self.store.async_get_users()
|
||||
return [
|
||||
credentials
|
||||
for user in users
|
||||
for credentials in user.credentials
|
||||
if (credentials.auth_provider_type == self.type and
|
||||
credentials.auth_provider_id == self.id)
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_create_credentials(self, data: Dict[str, str]) -> Credentials:
|
||||
"""Create credentials."""
|
||||
return Credentials(
|
||||
auth_provider_type=self.type,
|
||||
auth_provider_id=self.id,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Implement by extending class
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> 'LoginFlow':
|
||||
"""Return the data flow for logging in with auth provider.
|
||||
|
||||
Auth provider should extend LoginFlow and return an instance.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
async def auth_provider_from_config(
|
||||
hass: HomeAssistant, store: AuthStore,
|
||||
config: Dict[str, Any]) -> AuthProvider:
|
||||
"""Initialize an auth provider from a config."""
|
||||
provider_name = config[CONF_TYPE]
|
||||
module = await load_auth_provider_module(hass, provider_name)
|
||||
|
||||
try:
|
||||
config = module.CONFIG_SCHEMA(config) # type: ignore
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Invalid configuration for auth provider %s: %s',
|
||||
provider_name, humanize_error(config, err))
|
||||
raise
|
||||
|
||||
return AUTH_PROVIDERS[provider_name](hass, store, config) # type: ignore
|
||||
|
||||
|
||||
async def load_auth_provider_module(
|
||||
hass: HomeAssistant, provider: str) -> types.ModuleType:
|
||||
"""Load an auth provider."""
|
||||
try:
|
||||
module = importlib.import_module(
|
||||
'homeassistant.auth.providers.{}'.format(provider))
|
||||
except ImportError as err:
|
||||
_LOGGER.error('Unable to load auth provider %s: %s', provider, err)
|
||||
raise HomeAssistantError('Unable to load auth provider {}: {}'.format(
|
||||
provider, err))
|
||||
|
||||
if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'):
|
||||
return module
|
||||
|
||||
processed = hass.data.get(DATA_REQS)
|
||||
|
||||
if processed is None:
|
||||
processed = hass.data[DATA_REQS] = set()
|
||||
elif provider in processed:
|
||||
return module
|
||||
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
reqs = module.REQUIREMENTS # type: ignore
|
||||
req_success = await requirements.async_process_requirements(
|
||||
hass, 'auth provider {}'.format(provider), reqs)
|
||||
|
||||
if not req_success:
|
||||
raise HomeAssistantError(
|
||||
'Unable to process requirements of auth provider {}'.format(
|
||||
provider))
|
||||
|
||||
processed.add(provider)
|
||||
return module
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
def __init__(self, auth_provider: AuthProvider) -> None:
|
||||
"""Initialize the login flow."""
|
||||
self._auth_provider = auth_provider
|
||||
self._auth_module_id = None # type: Optional[str]
|
||||
self._auth_manager = auth_provider.hass.auth # type: ignore
|
||||
self.available_mfa_modules = {} # type: Dict[str, str]
|
||||
self.created_at = dt_util.utcnow()
|
||||
self.invalid_mfa_times = 0
|
||||
self.user = None # type: Optional[User]
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the first step of login flow.
|
||||
|
||||
Return self.async_show_form(step_id='init') if user_input == None.
|
||||
Return await self.async_finish(flow_result) if login init step pass.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_step_select_mfa_module(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of select mfa module."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
auth_module = user_input.get('multi_factor_auth_module')
|
||||
if auth_module in self.available_mfa_modules:
|
||||
self._auth_module_id = auth_module
|
||||
return await self.async_step_mfa()
|
||||
errors['base'] = 'invalid_auth_module'
|
||||
|
||||
if len(self.available_mfa_modules) == 1:
|
||||
self._auth_module_id = list(self.available_mfa_modules.keys())[0]
|
||||
return await self.async_step_mfa()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='select_mfa_module',
|
||||
data_schema=vol.Schema({
|
||||
'multi_factor_auth_module': vol.In(self.available_mfa_modules)
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_mfa(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of mfa validation."""
|
||||
assert self.user
|
||||
|
||||
errors = {}
|
||||
|
||||
auth_module = self._auth_manager.get_auth_mfa_module(
|
||||
self._auth_module_id)
|
||||
if auth_module is None:
|
||||
# Given an invalid input to async_step_select_mfa_module
|
||||
# will show invalid_auth_module error
|
||||
return await self.async_step_select_mfa_module(user_input={})
|
||||
|
||||
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)
|
||||
|
||||
if user_input is not None:
|
||||
expires = self.created_at + MFA_SESSION_EXPIRATION
|
||||
if dt_util.utcnow() > expires:
|
||||
return self.async_abort(
|
||||
reason='login_expired'
|
||||
)
|
||||
|
||||
result = await auth_module.async_validate(
|
||||
self.user.id, user_input)
|
||||
if not result:
|
||||
errors['base'] = 'invalid_code'
|
||||
self.invalid_mfa_times += 1
|
||||
if self.invalid_mfa_times >= auth_module.MAX_RETRY_TIME > 0:
|
||||
return self.async_abort(
|
||||
reason='too_many_retry'
|
||||
)
|
||||
|
||||
if not errors:
|
||||
return await self.async_finish(self.user)
|
||||
|
||||
description_placeholders = {
|
||||
'mfa_module_name': auth_module.name,
|
||||
'mfa_module_id': auth_module.id,
|
||||
} # type: Dict[str, Optional[str]]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='mfa',
|
||||
data_schema=auth_module.input_schema,
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_finish(self, flow_result: Any) -> Dict:
|
||||
"""Handle the pass of login flow."""
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=flow_result
|
||||
)
|
||||
273
homeassistant/auth/providers/homeassistant.py
Normal file
273
homeassistant/auth/providers/homeassistant.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Home Assistant auth provider."""
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
import bcrypt
|
||||
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
|
||||
STORAGE_KEY = 'auth_provider.homeassistant'
|
||||
|
||||
|
||||
def _disallow_id(conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Disallow ID in config."""
|
||||
if CONF_ID in conf:
|
||||
raise vol.Invalid(
|
||||
'ID is not allowed for the homeassistant auth provider.')
|
||||
|
||||
return conf
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id)
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Raised when we encounter invalid authentication."""
|
||||
|
||||
|
||||
class InvalidUser(HomeAssistantError):
|
||||
"""Raised when invalid user is specified.
|
||||
|
||||
Will not be raised when validating authentication.
|
||||
"""
|
||||
|
||||
|
||||
class Data:
|
||||
"""Hold the user data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the user data store."""
|
||||
self.hass = hass
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self._data = None # type: Optional[Dict[str, Any]]
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
data = await self._store.async_load()
|
||||
|
||||
if data is None:
|
||||
data = {
|
||||
'salt': generate_secret(),
|
||||
'users': []
|
||||
}
|
||||
|
||||
self._data = data
|
||||
|
||||
@property
|
||||
def users(self) -> List[Dict[str, str]]:
|
||||
"""Return users."""
|
||||
return self._data['users'] # type: ignore
|
||||
|
||||
def validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password.
|
||||
|
||||
Raises InvalidAuth if auth invalid.
|
||||
"""
|
||||
dummy = b'$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO'
|
||||
found = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
for user in self.users:
|
||||
if username == user['username']:
|
||||
found = user
|
||||
|
||||
if found is None:
|
||||
# check a hash to make timing the same as if user was found
|
||||
bcrypt.checkpw(b'foo',
|
||||
dummy)
|
||||
raise InvalidAuth
|
||||
|
||||
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."""
|
||||
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) \
|
||||
# type: bytes
|
||||
if for_storage:
|
||||
hashed = base64.b64encode(hashed)
|
||||
return hashed
|
||||
|
||||
def add_auth(self, username: str, password: str) -> None:
|
||||
"""Add a new authenticated user/pass."""
|
||||
if any(user['username'] == username for user in self.users):
|
||||
raise InvalidUser
|
||||
|
||||
self.users.append({
|
||||
'username': username,
|
||||
'password': self.hash_password(password, True).decode(),
|
||||
})
|
||||
|
||||
@callback
|
||||
def async_remove_auth(self, username: str) -> None:
|
||||
"""Remove authentication."""
|
||||
index = None
|
||||
for i, user in enumerate(self.users):
|
||||
if user['username'] == username:
|
||||
index = i
|
||||
break
|
||||
|
||||
if index is None:
|
||||
raise InvalidUser
|
||||
|
||||
self.users.pop(index)
|
||||
|
||||
def change_password(self, username: str, new_password: str) -> None:
|
||||
"""Update the password.
|
||||
|
||||
Raises InvalidUser if user cannot be found.
|
||||
"""
|
||||
for user in self.users:
|
||||
if user['username'] == username:
|
||||
user['password'] = self.hash_password(
|
||||
new_password, True).decode()
|
||||
break
|
||||
else:
|
||||
raise InvalidUser
|
||||
|
||||
async def async_save(self) -> None:
|
||||
"""Save data."""
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register('homeassistant')
|
||||
class HassAuthProvider(AuthProvider):
|
||||
"""Auth provider based on a local storage of users in HASS config dir."""
|
||||
|
||||
DEFAULT_TITLE = 'Home Assistant Local'
|
||||
|
||||
data = None
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize the auth provider."""
|
||||
if self.data is not None:
|
||||
return
|
||||
|
||||
self.data = Data(self.hass)
|
||||
await self.data.async_load()
|
||||
|
||||
async def async_login_flow(
|
||||
self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
return HassLoginFlow(self)
|
||||
|
||||
async def async_validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
if self.data is None:
|
||||
await self.async_initialize()
|
||||
assert self.data is not None
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self.data.validate_login, username, password)
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result['username']
|
||||
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data['username'] == username:
|
||||
return credential
|
||||
|
||||
# Create new credentials.
|
||||
return self.async_create_credentials({
|
||||
'username': username
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Get extra info for this credential."""
|
||||
return UserMeta(name=credentials.data['username'], is_active=True)
|
||||
|
||||
async def async_will_remove_credentials(
|
||||
self, credentials: Credentials) -> None:
|
||||
"""When credentials get removed, also remove the auth."""
|
||||
if self.data is None:
|
||||
await self.async_initialize()
|
||||
assert self.data is not None
|
||||
|
||||
try:
|
||||
self.data.async_remove_auth(credentials.data['username'])
|
||||
await self.data.async_save()
|
||||
except InvalidUser:
|
||||
# Can happen if somehow we didn't clean up a credential
|
||||
pass
|
||||
|
||||
|
||||
class HassLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await cast(HassAuthProvider, self._auth_provider)\
|
||||
.async_validate_login(user_input['username'],
|
||||
user_input['password'])
|
||||
except InvalidAuth:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
user_input.pop('password')
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema = OrderedDict() # type: Dict[str, type]
|
||||
schema['username'] = str
|
||||
schema['password'] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,13 +1,16 @@
|
||||
"""Example auth provider."""
|
||||
from collections import OrderedDict
|
||||
import hmac
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant import auth, data_entry_flow
|
||||
from homeassistant.core import callback
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
|
||||
USER_SCHEMA = vol.Schema({
|
||||
vol.Required('username'): str,
|
||||
@@ -16,7 +19,7 @@ USER_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||
vol.Required('users'): [USER_SCHEMA]
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
@@ -25,17 +28,17 @@ class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
@auth.AUTH_PROVIDERS.register('insecure_example')
|
||||
class ExampleAuthProvider(auth.AuthProvider):
|
||||
@AUTH_PROVIDERS.register('insecure_example')
|
||||
class ExampleAuthProvider(AuthProvider):
|
||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
||||
|
||||
async def async_credential_flow(self):
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
return LoginFlow(self)
|
||||
return ExampleLoginFlow(self)
|
||||
|
||||
@callback
|
||||
def async_validate_login(self, username, password):
|
||||
"""Helper to validate a username and password."""
|
||||
def async_validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
user = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
@@ -54,7 +57,8 @@ class ExampleAuthProvider(auth.AuthProvider):
|
||||
password.encode('utf-8')):
|
||||
raise InvalidAuthError
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result['username']
|
||||
|
||||
@@ -67,47 +71,45 @@ class ExampleAuthProvider(auth.AuthProvider):
|
||||
'username': username
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
username = credentials.data['username']
|
||||
name = None
|
||||
|
||||
for user in self.config['users']:
|
||||
if user['username'] == username:
|
||||
return {
|
||||
'name': user.get('name')
|
||||
}
|
||||
name = user.get('name')
|
||||
break
|
||||
|
||||
return {}
|
||||
return UserMeta(name=name, is_active=True)
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
class ExampleLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
def __init__(self, auth_provider):
|
||||
"""Initialize the login flow."""
|
||||
self._auth_provider = auth_provider
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
self._auth_provider.async_validate_login(
|
||||
user_input['username'], user_input['password'])
|
||||
cast(ExampleAuthProvider, self._auth_provider)\
|
||||
.async_validate_login(user_input['username'],
|
||||
user_input['password'])
|
||||
except InvalidAuthError:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=user_input
|
||||
)
|
||||
user_input.pop('password')
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema = OrderedDict()
|
||||
schema = OrderedDict() # type: Dict[str, type]
|
||||
schema['username'] = str
|
||||
schema['password'] = str
|
||||
|
||||
101
homeassistant/auth/providers/legacy_api_password.py
Normal file
101
homeassistant/auth/providers/legacy_api_password.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Support Legacy API password auth provider.
|
||||
|
||||
It will be removed when auth system production ready
|
||||
"""
|
||||
import hmac
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
|
||||
USER_SCHEMA = vol.Schema({
|
||||
vol.Required('username'): str,
|
||||
})
|
||||
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
LEGACY_USER_NAME = 'Legacy API password user'
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register('legacy_api_password')
|
||||
class LegacyApiPasswordAuthProvider(AuthProvider):
|
||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
||||
|
||||
DEFAULT_TITLE = 'Legacy API Password'
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
return LegacyLoginFlow(self)
|
||||
|
||||
@callback
|
||||
def async_validate_login(self, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP
|
||||
|
||||
if not hmac.compare_digest(hass_http.api_password.encode('utf-8'),
|
||||
password.encode('utf-8')):
|
||||
raise InvalidAuthError
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Return credentials for this login."""
|
||||
credentials = await self.async_credentials()
|
||||
if credentials:
|
||||
return credentials[0]
|
||||
|
||||
return self.async_create_credentials({})
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""
|
||||
Return info for the user.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
return UserMeta(name=LEGACY_USER_NAME, is_active=True)
|
||||
|
||||
|
||||
class LegacyLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
hass_http = getattr(self.hass, 'http', None)
|
||||
if hass_http is None or not hass_http.api_password:
|
||||
return self.async_abort(
|
||||
reason='no_api_password_set'
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
cast(LegacyApiPasswordAuthProvider, self._auth_provider)\
|
||||
.async_validate_login(user_input['password'])
|
||||
except InvalidAuthError:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return await self.async_finish({})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema({'password': str}),
|
||||
errors=errors,
|
||||
)
|
||||
129
homeassistant/auth/providers/trusted_networks.py
Normal file
129
homeassistant/auth/providers/trusted_networks.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Trusted Networks auth provider.
|
||||
|
||||
It shows list of users if access from trusted network.
|
||||
Abort login flow if not access from trusted network.
|
||||
"""
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when try to access from untrusted networks."""
|
||||
|
||||
|
||||
class InvalidUserError(HomeAssistantError):
|
||||
"""Raised when try to login as invalid user."""
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register('trusted_networks')
|
||||
class TrustedNetworksAuthProvider(AuthProvider):
|
||||
"""Trusted Networks auth provider.
|
||||
|
||||
Allow passwordless access from trusted network.
|
||||
"""
|
||||
|
||||
DEFAULT_TITLE = 'Trusted Networks'
|
||||
|
||||
@property
|
||||
def support_mfa(self) -> bool:
|
||||
"""Trusted Networks auth provider does not support MFA."""
|
||||
return False
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
assert context is not None
|
||||
users = await self.store.async_get_users()
|
||||
available_users = {user.id: user.name
|
||||
for user in users
|
||||
if not user.system_generated and user.is_active}
|
||||
|
||||
return TrustedNetworksLoginFlow(
|
||||
self, cast(str, context.get('ip_address')), available_users)
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
user_id = flow_result['user']
|
||||
|
||||
users = await self.store.async_get_users()
|
||||
for user in users:
|
||||
if (not user.system_generated and
|
||||
user.is_active and
|
||||
user.id == user_id):
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data['user_id'] == user_id:
|
||||
return credential
|
||||
cred = self.async_create_credentials({'user_id': user_id})
|
||||
await self.store.async_link_user(user, cred)
|
||||
return cred
|
||||
|
||||
# We only allow login as exist user
|
||||
raise InvalidUserError
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Trusted network auth provider should never create new user.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@callback
|
||||
def async_validate_access(self, ip_address: str) -> None:
|
||||
"""Make sure the access from trusted networks.
|
||||
|
||||
Raise InvalidAuthError if not.
|
||||
Raise InvalidAuthError if trusted_networks is not configured.
|
||||
"""
|
||||
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP
|
||||
|
||||
if not hass_http or not hass_http.trusted_networks:
|
||||
raise InvalidAuthError('trusted_networks is not configured')
|
||||
|
||||
if not any(ip_address in trusted_network for trusted_network
|
||||
in hass_http.trusted_networks):
|
||||
raise InvalidAuthError('Not in trusted_networks')
|
||||
|
||||
|
||||
class TrustedNetworksLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
def __init__(self, auth_provider: TrustedNetworksAuthProvider,
|
||||
ip_address: str, available_users: Dict[str, Optional[str]]) \
|
||||
-> None:
|
||||
"""Initialize the login flow."""
|
||||
super().__init__(auth_provider)
|
||||
self._available_users = available_users
|
||||
self._ip_address = ip_address
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
try:
|
||||
cast(TrustedNetworksAuthProvider, self._auth_provider)\
|
||||
.async_validate_access(self._ip_address)
|
||||
|
||||
except InvalidAuthError:
|
||||
return self.async_abort(
|
||||
reason='not_whitelisted'
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema({'user': vol.In(self._available_users)}),
|
||||
)
|
||||
13
homeassistant/auth/util.py
Normal file
13
homeassistant/auth/util.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Auth utils."""
|
||||
import binascii
|
||||
import os
|
||||
|
||||
|
||||
def generate_secret(entropy: int = 32) -> str:
|
||||
"""Generate a secret.
|
||||
|
||||
Backport of secrets.token_hex from Python 3.6
|
||||
|
||||
Event loop friendly.
|
||||
"""
|
||||
return binascii.hexlify(os.urandom(entropy)).decode('ascii')
|
||||
@@ -1 +0,0 @@
|
||||
"""Auth providers for Home Assistant."""
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Home Assistant auth provider."""
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import auth, data_entry_flow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import json
|
||||
|
||||
|
||||
PATH_DATA = '.users.json'
|
||||
|
||||
CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Raised when we encounter invalid authentication."""
|
||||
|
||||
|
||||
class InvalidUser(HomeAssistantError):
|
||||
"""Raised when invalid user is specified.
|
||||
|
||||
Will not be raised when validating authentication.
|
||||
"""
|
||||
|
||||
|
||||
class Data:
|
||||
"""Hold the user data."""
|
||||
|
||||
def __init__(self, path, data):
|
||||
"""Initialize the user data store."""
|
||||
self.path = path
|
||||
if data is None:
|
||||
data = {
|
||||
'salt': auth.generate_secret(),
|
||||
'users': []
|
||||
}
|
||||
self._data = data
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
"""Return users."""
|
||||
return self._data['users']
|
||||
|
||||
def validate_login(self, username, password):
|
||||
"""Validate a username and password.
|
||||
|
||||
Raises InvalidAuth if auth invalid.
|
||||
"""
|
||||
password = self.hash_password(password)
|
||||
|
||||
found = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
for user in self._data['users']:
|
||||
if username == user['username']:
|
||||
found = user
|
||||
|
||||
if found is None:
|
||||
# Do one more compare to make timing the same as if user was found.
|
||||
hmac.compare_digest(password, password)
|
||||
raise InvalidAuth
|
||||
|
||||
if not hmac.compare_digest(password,
|
||||
base64.b64decode(found['password'])):
|
||||
raise InvalidAuth
|
||||
|
||||
def hash_password(self, password, for_storage=False):
|
||||
"""Encode a password."""
|
||||
hashed = hashlib.pbkdf2_hmac(
|
||||
'sha512', password.encode(), self._data['salt'].encode(), 100000)
|
||||
if for_storage:
|
||||
hashed = base64.b64encode(hashed).decode()
|
||||
return hashed
|
||||
|
||||
def add_user(self, username, password):
|
||||
"""Add a user."""
|
||||
if any(user['username'] == username for user in self.users):
|
||||
raise InvalidUser
|
||||
|
||||
self.users.append({
|
||||
'username': username,
|
||||
'password': self.hash_password(password, True),
|
||||
})
|
||||
|
||||
def change_password(self, username, new_password):
|
||||
"""Update the password of a user.
|
||||
|
||||
Raises InvalidUser if user cannot be found.
|
||||
"""
|
||||
for user in self.users:
|
||||
if user['username'] == username:
|
||||
user['password'] = self.hash_password(new_password, True)
|
||||
break
|
||||
else:
|
||||
raise InvalidUser
|
||||
|
||||
def save(self):
|
||||
"""Save data."""
|
||||
json.save_json(self.path, self._data)
|
||||
|
||||
|
||||
def load_data(path):
|
||||
"""Load auth data."""
|
||||
return Data(path, json.load_json(path, None))
|
||||
|
||||
|
||||
@auth.AUTH_PROVIDERS.register('homeassistant')
|
||||
class HassAuthProvider(auth.AuthProvider):
|
||||
"""Auth provider based on a local storage of users in HASS config dir."""
|
||||
|
||||
DEFAULT_TITLE = 'Home Assistant Local'
|
||||
|
||||
async def async_credential_flow(self):
|
||||
"""Return a flow to login."""
|
||||
return LoginFlow(self)
|
||||
|
||||
async def async_validate_login(self, username, password):
|
||||
"""Helper to validate a username and password."""
|
||||
def validate():
|
||||
"""Validate creds."""
|
||||
data = self._auth_data()
|
||||
data.validate_login(username, password)
|
||||
|
||||
await self.hass.async_add_job(validate)
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result['username']
|
||||
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data['username'] == username:
|
||||
return credential
|
||||
|
||||
# Create new credentials.
|
||||
return self.async_create_credentials({
|
||||
'username': username
|
||||
})
|
||||
|
||||
def _auth_data(self):
|
||||
"""Return the auth provider data."""
|
||||
return load_data(self.hass.config.path(PATH_DATA))
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
def __init__(self, auth_provider):
|
||||
"""Initialize the login flow."""
|
||||
self._auth_provider = auth_provider
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self._auth_provider.async_validate_login(
|
||||
user_input['username'], user_input['password'])
|
||||
except InvalidAuth:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=user_input
|
||||
)
|
||||
|
||||
schema = OrderedDict()
|
||||
schema['username'] = str
|
||||
schema['password'] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Provide methods to bootstrap a Home Assistant instance."""
|
||||
import asyncio
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from time import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -17,10 +15,9 @@ from homeassistant.components import persistent_notification
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.logging import AsyncHandler
|
||||
from homeassistant.util.package import async_get_user_site, get_user_site
|
||||
from homeassistant.util.package import async_get_user_site, is_virtual_env
|
||||
from homeassistant.util.yaml import clear_secret_cache
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.signal import async_register_signal_handling
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,9 +26,8 @@ ERROR_LOG_FILENAME = 'home-assistant.log'
|
||||
# hass.data key for logging information.
|
||||
DATA_LOGGING = 'logging'
|
||||
|
||||
FIRST_INIT_COMPONENT = set((
|
||||
'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', 'logger',
|
||||
'introduction', 'frontend', 'history'))
|
||||
FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream',
|
||||
'logger', 'introduction', 'frontend', 'history'}
|
||||
|
||||
|
||||
def from_config_dict(config: Dict[str, Any],
|
||||
@@ -53,8 +49,9 @@ def from_config_dict(config: Dict[str, Any],
|
||||
if config_dir is not None:
|
||||
config_dir = os.path.abspath(config_dir)
|
||||
hass.config.config_dir = config_dir
|
||||
hass.loop.run_until_complete(
|
||||
async_mount_local_lib_path(config_dir, hass.loop))
|
||||
if not is_virtual_env():
|
||||
hass.loop.run_until_complete(
|
||||
async_mount_local_lib_path(config_dir))
|
||||
|
||||
# run task
|
||||
hass = hass.loop.run_until_complete(
|
||||
@@ -62,7 +59,6 @@ def from_config_dict(config: Dict[str, Any],
|
||||
config, hass, config_dir, enable_log, verbose, skip_pip,
|
||||
log_rotate_days, log_file, log_no_color)
|
||||
)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
@@ -88,14 +84,24 @@ async def async_from_config_dict(config: Dict[str, Any],
|
||||
log_no_color)
|
||||
|
||||
core_config = config.get(core.DOMAIN, {})
|
||||
has_api_password = bool((config.get('http') or {}).get('api_password'))
|
||||
has_trusted_networks = bool((config.get('http') or {})
|
||||
.get('trusted_networks'))
|
||||
|
||||
try:
|
||||
await conf_util.async_process_ha_core_config(hass, core_config)
|
||||
except vol.Invalid as ex:
|
||||
conf_util.async_log_exception(ex, 'homeassistant', core_config, hass)
|
||||
await conf_util.async_process_ha_core_config(
|
||||
hass, core_config, has_api_password, has_trusted_networks)
|
||||
except vol.Invalid as config_err:
|
||||
conf_util.async_log_exception(
|
||||
config_err, 'homeassistant', core_config, hass)
|
||||
return None
|
||||
except HomeAssistantError:
|
||||
_LOGGER.error("Home Assistant core failed to initialize. "
|
||||
"Further initialization aborted")
|
||||
return None
|
||||
|
||||
await hass.async_add_job(conf_util.process_ha_config_upgrade, hass)
|
||||
await hass.async_add_executor_job(
|
||||
conf_util.process_ha_config_upgrade, hass)
|
||||
|
||||
hass.config.skip_pip = skip_pip
|
||||
if skip_pip:
|
||||
@@ -123,11 +129,10 @@ async def async_from_config_dict(config: Dict[str, Any],
|
||||
components.update(hass.config_entries.async_domains())
|
||||
|
||||
# setup components
|
||||
# pylint: disable=not-an-iterable
|
||||
res = await core_components.async_setup(hass, config)
|
||||
if not res:
|
||||
_LOGGER.error("Home Assistant core failed to initialize. "
|
||||
"further initialization aborted")
|
||||
"Further initialization aborted")
|
||||
return hass
|
||||
|
||||
await persistent_notification.async_setup(hass, config)
|
||||
@@ -138,7 +143,7 @@ async def async_from_config_dict(config: Dict[str, Any],
|
||||
for component in components:
|
||||
if component not in FIRST_INIT_COMPONENT:
|
||||
continue
|
||||
hass.async_add_job(async_setup_component(hass, component, config))
|
||||
hass.async_create_task(async_setup_component(hass, component, config))
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -146,14 +151,13 @@ async def async_from_config_dict(config: Dict[str, Any],
|
||||
for component in components:
|
||||
if component in FIRST_INIT_COMPONENT:
|
||||
continue
|
||||
hass.async_add_job(async_setup_component(hass, component, config))
|
||||
hass.async_create_task(async_setup_component(hass, component, config))
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
stop = time()
|
||||
_LOGGER.info("Home Assistant initialized in %.2fs", stop-start)
|
||||
|
||||
async_register_signal_handling(hass)
|
||||
return hass
|
||||
|
||||
|
||||
@@ -163,7 +167,8 @@ def from_config_file(config_path: str,
|
||||
skip_pip: bool = True,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False):
|
||||
log_no_color: bool = False)\
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter if given,
|
||||
@@ -188,7 +193,8 @@ async def async_from_config_file(config_path: str,
|
||||
skip_pip: bool = True,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False):
|
||||
log_no_color: bool = False)\
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter.
|
||||
@@ -197,13 +203,15 @@ async def async_from_config_file(config_path: str,
|
||||
# Set config dir to directory holding config file
|
||||
config_dir = os.path.abspath(os.path.dirname(config_path))
|
||||
hass.config.config_dir = config_dir
|
||||
await async_mount_local_lib_path(config_dir, hass.loop)
|
||||
|
||||
if not is_virtual_env():
|
||||
await async_mount_local_lib_path(config_dir)
|
||||
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file,
|
||||
log_no_color)
|
||||
|
||||
try:
|
||||
config_dict = await hass.async_add_job(
|
||||
config_dict = await hass.async_add_executor_job(
|
||||
conf_util.load_yaml_config_file, config_path)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error("Error loading %s: %s", config_path, err)
|
||||
@@ -211,16 +219,15 @@ async def async_from_config_file(config_path: str,
|
||||
finally:
|
||||
clear_secret_cache()
|
||||
|
||||
hass = await async_from_config_dict(
|
||||
return await async_from_config_dict(
|
||||
config_dict, hass, enable_log=False, skip_pip=skip_pip)
|
||||
return hass
|
||||
|
||||
|
||||
@core.callback
|
||||
def async_enable_logging(hass: core.HomeAssistant,
|
||||
verbose: bool = False,
|
||||
log_rotate_days=None,
|
||||
log_file=None,
|
||||
log_rotate_days: Optional[int] = None,
|
||||
log_file: Optional[str] = None,
|
||||
log_no_color: bool = False) -> None:
|
||||
"""Set up the logging.
|
||||
|
||||
@@ -289,9 +296,9 @@ def async_enable_logging(hass: core.HomeAssistant,
|
||||
|
||||
async_handler = AsyncHandler(hass.loop, err_handler)
|
||||
|
||||
async def async_stop_async_handler(event):
|
||||
async def async_stop_async_handler(_: Any) -> None:
|
||||
"""Cleanup async handler."""
|
||||
logging.getLogger('').removeHandler(async_handler)
|
||||
logging.getLogger('').removeHandler(async_handler) # type: ignore
|
||||
await async_handler.async_close(blocking=True)
|
||||
|
||||
hass.bus.async_listen_once(
|
||||
@@ -305,26 +312,16 @@ def async_enable_logging(hass: core.HomeAssistant,
|
||||
hass.data[DATA_LOGGING] = err_log_path
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Unable to setup error log %s (access denied)", err_log_path)
|
||||
"Unable to set up error log %s (access denied)", err_log_path)
|
||||
|
||||
|
||||
def mount_local_lib_path(config_dir: str) -> str:
|
||||
"""Add local library to Python Path."""
|
||||
deps_dir = os.path.join(config_dir, 'deps')
|
||||
lib_dir = get_user_site(deps_dir)
|
||||
if lib_dir not in sys.path:
|
||||
sys.path.insert(0, lib_dir)
|
||||
return deps_dir
|
||||
|
||||
|
||||
async def async_mount_local_lib_path(config_dir: str,
|
||||
loop: asyncio.AbstractEventLoop) -> str:
|
||||
async def async_mount_local_lib_path(config_dir: str) -> str:
|
||||
"""Add local library to Python Path.
|
||||
|
||||
This function is a coroutine.
|
||||
"""
|
||||
deps_dir = os.path.join(config_dir, 'deps')
|
||||
lib_dir = await async_get_user_site(deps_dir, loop=loop)
|
||||
lib_dir = await async_get_user_site(deps_dir)
|
||||
if lib_dir not in sys.path:
|
||||
sys.path.insert(0, lib_dir)
|
||||
return deps_dir
|
||||
|
||||
@@ -10,6 +10,7 @@ Component design guidelines:
|
||||
import asyncio
|
||||
import itertools as it
|
||||
import logging
|
||||
from typing import Awaitable
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.config as conf_util
|
||||
@@ -109,7 +110,7 @@ def async_reload_core_config(hass):
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]:
|
||||
"""Set up general services related to Home Assistant."""
|
||||
@asyncio.coroutine
|
||||
def async_handle_turn_service(service):
|
||||
@@ -167,7 +168,7 @@ def async_setup(hass, config):
|
||||
def async_handle_core_service(call):
|
||||
"""Service handler for handling core services."""
|
||||
if call.service == SERVICE_HOMEASSISTANT_STOP:
|
||||
hass.async_add_job(hass.async_stop())
|
||||
hass.async_create_task(hass.async_stop())
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -183,7 +184,7 @@ def async_setup(hass, config):
|
||||
return
|
||||
|
||||
if call.service == SERVICE_HOMEASSISTANT_RESTART:
|
||||
hass.async_add_job(hass.async_stop(RESTART_EXIT_CODE))
|
||||
hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE))
|
||||
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service)
|
||||
|
||||
@@ -85,7 +85,7 @@ ABODE_PLATFORMS = [
|
||||
]
|
||||
|
||||
|
||||
class AbodeSystem(object):
|
||||
class AbodeSystem:
|
||||
"""Abode System class."""
|
||||
|
||||
def __init__(self, username, password, cache,
|
||||
|
||||
@@ -110,7 +110,7 @@ NotificationItem = namedtuple(
|
||||
)
|
||||
|
||||
|
||||
class AdsHub(object):
|
||||
class AdsHub:
|
||||
"""Representation of an ADS connection."""
|
||||
|
||||
def __init__(self, ads_client):
|
||||
|
||||
@@ -26,20 +26,6 @@ ATTR_CHANGED_BY = 'changed_by'
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
SERVICE_TO_METHOD = {
|
||||
SERVICE_ALARM_DISARM: 'alarm_disarm',
|
||||
SERVICE_ALARM_ARM_HOME: 'alarm_arm_home',
|
||||
SERVICE_ALARM_ARM_AWAY: 'alarm_arm_away',
|
||||
SERVICE_ALARM_ARM_NIGHT: 'alarm_arm_night',
|
||||
SERVICE_ALARM_ARM_CUSTOM_BYPASS: 'alarm_arm_custom_bypass',
|
||||
SERVICE_ALARM_TRIGGER: 'alarm_trigger'
|
||||
}
|
||||
|
||||
ATTR_TO_PROPERTY = [
|
||||
ATTR_CODE,
|
||||
ATTR_CODE_FORMAT
|
||||
]
|
||||
|
||||
ALARM_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_CODE): cv.string,
|
||||
@@ -121,39 +107,49 @@ def alarm_arm_custom_bypass(hass, code=None, entity_id=None):
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Track states and offer events for sensors."""
|
||||
component = EntityComponent(
|
||||
component = hass.data[DOMAIN] = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
yield from component.async_setup(config)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_service_handler(service):
|
||||
"""Map services to methods on Alarm."""
|
||||
target_alarms = component.async_extract_from_service(service)
|
||||
|
||||
code = service.data.get(ATTR_CODE)
|
||||
|
||||
method = "async_{}".format(SERVICE_TO_METHOD[service.service])
|
||||
|
||||
update_tasks = []
|
||||
for alarm in target_alarms:
|
||||
yield from getattr(alarm, method)(code)
|
||||
|
||||
if not alarm.should_poll:
|
||||
continue
|
||||
update_tasks.append(alarm.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
for service in SERVICE_TO_METHOD:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_alarm_service_handler,
|
||||
schema=ALARM_SERVICE_SCHEMA)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_ALARM_DISARM, ALARM_SERVICE_SCHEMA,
|
||||
'async_alarm_disarm'
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_ALARM_ARM_HOME, ALARM_SERVICE_SCHEMA,
|
||||
'async_alarm_arm_home'
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_ALARM_ARM_AWAY, ALARM_SERVICE_SCHEMA,
|
||||
'async_alarm_arm_away'
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_ALARM_ARM_NIGHT, ALARM_SERVICE_SCHEMA,
|
||||
'async_alarm_arm_night'
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_ALARM_ARM_CUSTOM_BYPASS, ALARM_SERVICE_SCHEMA,
|
||||
'async_alarm_arm_custom_bypass'
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_ALARM_TRIGGER, ALARM_SERVICE_SCHEMA,
|
||||
'async_alarm_trigger'
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DOMAIN].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DOMAIN].async_unload_entry(entry)
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
class AlarmControlPanel(Entity):
|
||||
"""An abstract class for alarm control devices."""
|
||||
@@ -177,7 +173,7 @@ class AlarmControlPanel(Entity):
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
return self.hass.async_add_job(self.alarm_disarm, code)
|
||||
return self.hass.async_add_executor_job(self.alarm_disarm, code)
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
@@ -188,7 +184,7 @@ class AlarmControlPanel(Entity):
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
return self.hass.async_add_job(self.alarm_arm_home, code)
|
||||
return self.hass.async_add_executor_job(self.alarm_arm_home, code)
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
@@ -199,7 +195,7 @@ class AlarmControlPanel(Entity):
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
return self.hass.async_add_job(self.alarm_arm_away, code)
|
||||
return self.hass.async_add_executor_job(self.alarm_arm_away, code)
|
||||
|
||||
def alarm_arm_night(self, code=None):
|
||||
"""Send arm night command."""
|
||||
@@ -210,7 +206,7 @@ class AlarmControlPanel(Entity):
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
return self.hass.async_add_job(self.alarm_arm_night, code)
|
||||
return self.hass.async_add_executor_job(self.alarm_arm_night, code)
|
||||
|
||||
def alarm_trigger(self, code=None):
|
||||
"""Send alarm trigger command."""
|
||||
@@ -221,7 +217,7 @@ class AlarmControlPanel(Entity):
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
return self.hass.async_add_job(self.alarm_trigger, code)
|
||||
return self.hass.async_add_executor_job(self.alarm_trigger, code)
|
||||
|
||||
def alarm_arm_custom_bypass(self, code=None):
|
||||
"""Send arm custom bypass command."""
|
||||
@@ -232,7 +228,8 @@ class AlarmControlPanel(Entity):
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
return self.hass.async_add_job(self.alarm_arm_custom_bypass, code)
|
||||
return self.hass.async_add_executor_job(
|
||||
self.alarm_arm_custom_bypass, code)
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
|
||||
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
ICON = 'mdi:security'
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up an alarm control panel for an Abode device."""
|
||||
data = hass.data[ABODE_DOMAIN]
|
||||
|
||||
@@ -28,7 +28,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
|
||||
data.devices.extend(alarm_devices)
|
||||
|
||||
add_devices(alarm_devices)
|
||||
add_entities(alarm_devices)
|
||||
|
||||
|
||||
class AbodeAlarm(AbodeDevice, AlarmControlPanel):
|
||||
|
||||
@@ -26,10 +26,10 @@ ALARM_TOGGLE_CHIME_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up for AlarmDecoder alarm panels."""
|
||||
device = AlarmDecoderAlarmPanel()
|
||||
add_devices([device])
|
||||
add_entities([device])
|
||||
|
||||
def alarm_toggle_chime_handler(service):
|
||||
"""Register toggle chime handler."""
|
||||
@@ -100,8 +100,8 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the regex for code format or None if no code is required."""
|
||||
return '^\\d{4,6}$'
|
||||
"""Return one or more digits/characters."""
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
||||
@@ -6,6 +6,7 @@ https://home-assistant.io/components/alarm_control_panel.alarmdotcom/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -32,7 +33,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up a Alarm.com control panel."""
|
||||
name = config.get(CONF_NAME)
|
||||
code = config.get(CONF_CODE)
|
||||
@@ -41,7 +43,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
|
||||
alarmdotcom = AlarmDotCom(hass, name, code, username, password)
|
||||
yield from alarmdotcom.async_login()
|
||||
async_add_devices([alarmdotcom])
|
||||
async_add_entities([alarmdotcom])
|
||||
|
||||
|
||||
class AlarmDotCom(alarm.AlarmControlPanel):
|
||||
@@ -79,17 +81,21 @@ class AlarmDotCom(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters if code is defined."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
if isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
if self._alarm.state.lower() == 'disarmed':
|
||||
return STATE_ALARM_DISARMED
|
||||
elif self._alarm.state.lower() == 'armed stay':
|
||||
if self._alarm.state.lower() == 'armed stay':
|
||||
return STATE_ALARM_ARMED_HOME
|
||||
elif self._alarm.state.lower() == 'armed away':
|
||||
if self._alarm.state.lower() == 'armed away':
|
||||
return STATE_ALARM_ARMED_AWAY
|
||||
return STATE_UNKNOWN
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ Support for Arlo Alarm Control Panels.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.arlo/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanel, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.arlo import (DATA_ARLO, CONF_ATTRIBUTION)
|
||||
from homeassistant.components.arlo import (
|
||||
DATA_ARLO, CONF_ATTRIBUTION, SIGNAL_UPDATE_ARLO)
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_DISARMED)
|
||||
@@ -36,21 +38,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Arlo Alarm Control Panels."""
|
||||
data = hass.data[DATA_ARLO]
|
||||
arlo = hass.data[DATA_ARLO]
|
||||
|
||||
if not data.base_stations:
|
||||
if not arlo.base_stations:
|
||||
return
|
||||
|
||||
home_mode_name = config.get(CONF_HOME_MODE_NAME)
|
||||
away_mode_name = config.get(CONF_AWAY_MODE_NAME)
|
||||
base_stations = []
|
||||
for base_station in data.base_stations:
|
||||
for base_station in arlo.base_stations:
|
||||
base_stations.append(ArloBaseStation(base_station, home_mode_name,
|
||||
away_mode_name))
|
||||
async_add_devices(base_stations, True)
|
||||
add_entities(base_stations, True)
|
||||
|
||||
|
||||
class ArloBaseStation(AlarmControlPanel):
|
||||
@@ -68,6 +69,16 @@ class ArloBaseStation(AlarmControlPanel):
|
||||
"""Return icon."""
|
||||
return ICON
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_UPDATE_ARLO, self._update_callback)
|
||||
|
||||
@callback
|
||||
def _update_callback(self):
|
||||
"""Call update method."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
@@ -75,30 +86,22 @@ class ArloBaseStation(AlarmControlPanel):
|
||||
|
||||
def update(self):
|
||||
"""Update the state of the device."""
|
||||
# PyArlo sometimes returns None for mode. So retry 3 times before
|
||||
# returning None.
|
||||
num_retries = 3
|
||||
i = 0
|
||||
while i < num_retries:
|
||||
mode = self._base_station.mode
|
||||
if mode:
|
||||
self._state = self._get_state_from_mode(mode)
|
||||
return
|
||||
i += 1
|
||||
self._state = None
|
||||
_LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name)
|
||||
mode = self._base_station.mode
|
||||
if mode:
|
||||
self._state = self._get_state_from_mode(mode)
|
||||
else:
|
||||
self._state = None
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_disarm(self, code=None):
|
||||
async def async_alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
self._base_station.mode = DISARMED
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_away(self, code=None):
|
||||
async def async_alarm_arm_away(self, code=None):
|
||||
"""Send arm away command. Uses custom mode."""
|
||||
self._base_station.mode = self._away_mode_name
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_home(self, code=None):
|
||||
async def async_alarm_arm_home(self, code=None):
|
||||
"""Send arm home command. Uses custom mode."""
|
||||
self._base_station.mode = self._home_mode_name
|
||||
|
||||
@@ -119,10 +122,10 @@ class ArloBaseStation(AlarmControlPanel):
|
||||
"""Convert Arlo mode to Home Assistant state."""
|
||||
if mode == ARMED:
|
||||
return STATE_ALARM_ARMED_AWAY
|
||||
elif mode == DISARMED:
|
||||
if mode == DISARMED:
|
||||
return STATE_ALARM_DISARMED
|
||||
elif mode == self._home_mode_name:
|
||||
if mode == self._home_mode_name:
|
||||
return STATE_ALARM_ARMED_HOME
|
||||
elif mode == self._away_mode_name:
|
||||
if mode == self._away_mode_name:
|
||||
return STATE_ALARM_ARMED_AWAY
|
||||
return None
|
||||
return mode
|
||||
|
||||
@@ -16,7 +16,7 @@ DEPENDENCIES = ['canary']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Canary alarms."""
|
||||
data = hass.data[DATA_CANARY]
|
||||
devices = []
|
||||
@@ -24,7 +24,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
for location in data.locations:
|
||||
devices.append(CanaryAlarm(data, location.location_id))
|
||||
|
||||
add_devices(devices, True)
|
||||
add_entities(devices, True)
|
||||
|
||||
|
||||
class CanaryAlarm(AlarmControlPanel):
|
||||
@@ -55,9 +55,9 @@ class CanaryAlarm(AlarmControlPanel):
|
||||
mode = location.mode
|
||||
if mode.name == LOCATION_MODE_AWAY:
|
||||
return STATE_ALARM_ARMED_AWAY
|
||||
elif mode.name == LOCATION_MODE_HOME:
|
||||
if mode.name == LOCATION_MODE_HOME:
|
||||
return STATE_ALARM_ARMED_HOME
|
||||
elif mode.name == LOCATION_MODE_NIGHT:
|
||||
if mode.name == LOCATION_MODE_NIGHT:
|
||||
return STATE_ALARM_ARMED_NIGHT
|
||||
return None
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Concord232 alarm control panel platform."""
|
||||
name = config.get(CONF_NAME)
|
||||
host = config.get(CONF_HOST)
|
||||
@@ -44,7 +44,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
url = 'http://{}:{}'.format(host, port)
|
||||
|
||||
try:
|
||||
add_devices([Concord232Alarm(hass, url, name)])
|
||||
add_entities([Concord232Alarm(hass, url, name)])
|
||||
except requests.exceptions.ConnectionError as ex:
|
||||
_LOGGER.error("Unable to connect to Concord232: %s", str(ex))
|
||||
return
|
||||
@@ -80,7 +80,7 @@ class Concord232Alarm(alarm.AlarmControlPanel):
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the characters if code is defined."""
|
||||
return '[0-9]{4}([0-9]{2})?'
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
||||
@@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation
|
||||
https://home-assistant.io/components/demo/
|
||||
"""
|
||||
import datetime
|
||||
import homeassistant.components.alarm_control_panel.manual as manual
|
||||
from homeassistant.components.alarm_control_panel import manual
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS,
|
||||
STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
|
||||
@@ -13,9 +13,9 @@ from homeassistant.const import (
|
||||
CONF_PENDING_TIME, CONF_TRIGGER_TIME)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Demo alarm control panel platform."""
|
||||
add_devices([
|
||||
add_entities([
|
||||
manual.ManualAlarm(hass, 'Alarm', '1234', None, False, {
|
||||
STATE_ALARM_ARMED_AWAY: {
|
||||
CONF_DELAY_TIME: datetime.timedelta(seconds=0),
|
||||
|
||||
@@ -34,7 +34,7 @@ STATES = {
|
||||
}
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Egardia platform."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
@@ -45,7 +45,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
discovery_info.get(CONF_REPORT_SERVER_CODES),
|
||||
discovery_info[CONF_REPORT_SERVER_PORT])
|
||||
# add egardia alarm device
|
||||
add_devices([device], True)
|
||||
add_entities([device], True)
|
||||
|
||||
|
||||
class EgardiaAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@@ -33,7 +33,8 @@ ALARM_KEYPRESS_SCHEMA = vol.Schema({
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Perform the setup for Envisalink alarm panels."""
|
||||
configured_partitions = discovery_info['partitions']
|
||||
code = discovery_info[CONF_CODE]
|
||||
@@ -53,7 +54,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
)
|
||||
devices.append(device)
|
||||
|
||||
async_add_devices(devices)
|
||||
async_add_entities(devices)
|
||||
|
||||
@callback
|
||||
def alarm_keypress_handler(service):
|
||||
@@ -106,7 +107,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel):
|
||||
"""Regex for code format or None if no code is required."""
|
||||
if self._code:
|
||||
return None
|
||||
return '^\\d{4,6}$'
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Support for HomematicIP Cloud alarm control panel.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.homematicip_cloud/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanel
|
||||
from homeassistant.components.homematicip_cloud import (
|
||||
HMIPC_HAPID, HomematicipGenericDevice)
|
||||
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_TRIGGERED)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['homematicip_cloud']
|
||||
|
||||
HMIP_ZONE_AWAY = 'EXTERNAL'
|
||||
HMIP_ZONE_HOME = 'INTERNAL'
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the HomematicIP Cloud alarm control devices."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the HomematicIP alarm control panel from a config entry."""
|
||||
from homematicip.aio.group import AsyncSecurityZoneGroup
|
||||
|
||||
home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
|
||||
devices = []
|
||||
for group in home.groups:
|
||||
if isinstance(group, AsyncSecurityZoneGroup):
|
||||
devices.append(HomematicipSecurityZone(home, group))
|
||||
|
||||
if devices:
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel):
|
||||
"""Representation of an HomematicIP Cloud security zone group."""
|
||||
|
||||
def __init__(self, home, device):
|
||||
"""Initialize the security zone group."""
|
||||
device.modelType = 'Group-SecurityZone'
|
||||
device.windowState = ''
|
||||
super().__init__(home, device)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
from homematicip.base.enums import WindowState
|
||||
|
||||
if self._device.active:
|
||||
if (self._device.sabotage or self._device.motionDetected or
|
||||
self._device.windowState == WindowState.OPEN):
|
||||
return STATE_ALARM_TRIGGERED
|
||||
|
||||
active = self._home.get_security_zones_activation()
|
||||
if active == (True, True):
|
||||
return STATE_ALARM_ARMED_AWAY
|
||||
if active == (False, True):
|
||||
return STATE_ALARM_ARMED_HOME
|
||||
|
||||
return STATE_ALARM_DISARMED
|
||||
|
||||
async def async_alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
await self._home.set_security_zones_activation(False, False)
|
||||
|
||||
async def async_alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
await self._home.set_security_zones_activation(True, False)
|
||||
|
||||
async def async_alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
await self._home.set_security_zones_activation(True, True)
|
||||
@@ -40,7 +40,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up an iAlarm control panel."""
|
||||
name = config.get(CONF_NAME)
|
||||
username = config.get(CONF_USERNAME)
|
||||
@@ -49,7 +49,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
|
||||
url = 'http://{}'.format(host)
|
||||
ialarm = IAlarmPanel(name, username, password, url)
|
||||
add_devices([ialarm], True)
|
||||
add_entities([ialarm], True)
|
||||
|
||||
|
||||
class IAlarmPanel(alarm.AlarmControlPanel):
|
||||
|
||||
@@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.ifttt/
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -58,7 +59,7 @@ PUSH_ALARM_STATE_SERVICE_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up a control panel managed through IFTTT."""
|
||||
if DATA_IFTTT_ALARM not in hass.data:
|
||||
hass.data[DATA_IFTTT_ALARM] = []
|
||||
@@ -74,7 +75,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
alarmpanel = IFTTTAlarmPanel(name, code, event_away, event_home,
|
||||
event_night, event_disarm, optimistic)
|
||||
hass.data[DATA_IFTTT_ALARM].append(alarmpanel)
|
||||
add_devices([alarmpanel])
|
||||
add_entities([alarmpanel])
|
||||
|
||||
async def push_state_update(service):
|
||||
"""Set the service state as device state attribute."""
|
||||
@@ -124,8 +125,12 @@ class IFTTTAlarmPanel(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
if isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
|
||||
@@ -7,6 +7,7 @@ https://home-assistant.io/components/alarm_control_panel.manual/
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -102,9 +103,9 @@ PLATFORM_SCHEMA = vol.Schema(vol.All({
|
||||
}, _state_validator))
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the manual alarm platform."""
|
||||
add_devices([ManualAlarm(
|
||||
add_entities([ManualAlarm(
|
||||
hass,
|
||||
config[CONF_NAME],
|
||||
config.get(CONF_CODE),
|
||||
@@ -201,8 +202,12 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
if isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
|
||||
@@ -8,6 +8,7 @@ import asyncio
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -18,7 +19,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED,
|
||||
CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_DELAY_TIME, CONF_PENDING_TIME,
|
||||
CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER)
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
from homeassistant.components import mqtt
|
||||
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.core import callback
|
||||
@@ -122,9 +123,9 @@ PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
|
||||
}), _state_validator))
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the manual MQTT alarm platform."""
|
||||
add_devices([ManualMQTTAlarm(
|
||||
add_entities([ManualMQTTAlarm(
|
||||
hass,
|
||||
config[CONF_NAME],
|
||||
config.get(CONF_CODE),
|
||||
@@ -237,8 +238,12 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
if isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
|
||||
@@ -6,12 +6,13 @@ https://home-assistant.io/components/alarm_control_panel.mqtt/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN,
|
||||
@@ -19,7 +20,7 @@ from homeassistant.const import (
|
||||
from homeassistant.components.mqtt import (
|
||||
CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC,
|
||||
CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS,
|
||||
MqttAvailability)
|
||||
CONF_RETAIN, MqttAvailability)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -46,13 +47,18 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up the MQTT Alarm Control Panel platform."""
|
||||
async_add_devices([MqttAlarm(
|
||||
if discovery_info is not None:
|
||||
config = PLATFORM_SCHEMA(discovery_info)
|
||||
|
||||
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),
|
||||
@@ -65,9 +71,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel):
|
||||
"""Representation of a MQTT alarm status."""
|
||||
|
||||
def __init__(self, name, state_topic, command_topic, qos, payload_disarm,
|
||||
payload_arm_home, payload_arm_away, code, availability_topic,
|
||||
payload_available, payload_not_available):
|
||||
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):
|
||||
"""Init the MQTT Alarm Control Panel."""
|
||||
super().__init__(availability_topic, qos, payload_available,
|
||||
payload_not_available)
|
||||
@@ -76,6 +82,7 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel):
|
||||
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
|
||||
@@ -117,8 +124,12 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""One or more characters if code is defined."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
if isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_disarm(self, code=None):
|
||||
@@ -129,7 +140,8 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel):
|
||||
if not self._validate_code(code, 'disarming'):
|
||||
return
|
||||
mqtt.async_publish(
|
||||
self.hass, self._command_topic, self._payload_disarm, self._qos)
|
||||
self.hass, self._command_topic, self._payload_disarm, self._qos,
|
||||
self._retain)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_home(self, code=None):
|
||||
@@ -140,7 +152,8 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel):
|
||||
if not self._validate_code(code, 'arming home'):
|
||||
return
|
||||
mqtt.async_publish(
|
||||
self.hass, self._command_topic, self._payload_arm_home, self._qos)
|
||||
self.hass, self._command_topic, self._payload_arm_home, self._qos,
|
||||
self._retain)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_away(self, code=None):
|
||||
@@ -151,7 +164,8 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel):
|
||||
if not self._validate_code(code, 'arming away'):
|
||||
return
|
||||
mqtt.async_publish(
|
||||
self.hass, self._command_topic, self._payload_arm_away, self._qos)
|
||||
self.hass, self._command_topic, self._payload_arm_away, self._qos,
|
||||
self._retain)
|
||||
|
||||
def _validate_code(self, code, state):
|
||||
"""Validate given code."""
|
||||
|
||||
@@ -31,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the NX584 platform."""
|
||||
name = config.get(CONF_NAME)
|
||||
host = config.get(CONF_HOST)
|
||||
@@ -40,7 +40,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
url = 'http://{}:{}'.format(host, port)
|
||||
|
||||
try:
|
||||
add_devices([NX584Alarm(hass, url, name)])
|
||||
add_entities([NX584Alarm(hass, url, name)])
|
||||
except requests.exceptions.ConnectionError as ex:
|
||||
_LOGGER.error("Unable to connect to NX584: %s", str(ex))
|
||||
return False
|
||||
@@ -69,8 +69,8 @@ class NX584Alarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return che characters if code is defined."""
|
||||
return '[0-9]{4}([0-9]{2})?'
|
||||
"""Return one or more digits/characters."""
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
||||
@@ -19,14 +19,15 @@ DEPENDENCIES = ['satel_integra']
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up for Satel Integra alarm panels."""
|
||||
if not discovery_info:
|
||||
return
|
||||
|
||||
device = SatelIntegraAlarmPanel(
|
||||
"Alarm Panel", discovery_info.get(CONF_ARM_HOME_MODE))
|
||||
async_add_devices([device])
|
||||
async_add_entities([device])
|
||||
|
||||
|
||||
class SatelIntegraAlarmPanel(alarm.AlarmControlPanel):
|
||||
@@ -66,7 +67,7 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanel):
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the regex for code format or None if no code is required."""
|
||||
return '^\\d{4,6}$'
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
||||
@@ -5,26 +5,26 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.simplisafe/
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
PLATFORM_SCHEMA, AlarmControlPanel)
|
||||
from homeassistant.const import (
|
||||
CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_DISARMED, STATE_UNKNOWN)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['simplisafe-python==1.0.5']
|
||||
REQUIREMENTS = ['simplisafe-python==2.0.2']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'SimpliSafe'
|
||||
DOMAIN = 'simplisafe'
|
||||
|
||||
NOTIFICATION_ID = 'simplisafe_notification'
|
||||
NOTIFICATION_TITLE = 'SimpliSafe Setup'
|
||||
ATTR_ALARM_ACTIVE = "alarm_active"
|
||||
ATTR_TEMPERATURE = "temperature"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
@@ -34,38 +34,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the SimpliSafe platform."""
|
||||
from simplipy.api import SimpliSafeApiInterface, get_systems
|
||||
from simplipy.api import SimpliSafeApiInterface, SimpliSafeAPIException
|
||||
name = config.get(CONF_NAME)
|
||||
code = config.get(CONF_CODE)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
|
||||
simplisafe = SimpliSafeApiInterface()
|
||||
status = simplisafe.set_credentials(username, password)
|
||||
if status:
|
||||
hass.data[DOMAIN] = simplisafe
|
||||
locations = get_systems(simplisafe)
|
||||
for location in locations:
|
||||
add_devices([SimpliSafeAlarm(location, name, code)])
|
||||
else:
|
||||
message = 'Failed to log into SimpliSafe. Check credentials.'
|
||||
_LOGGER.error(message)
|
||||
hass.components.persistent_notification.create(
|
||||
message,
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID)
|
||||
return False
|
||||
try:
|
||||
simplisafe = SimpliSafeApiInterface(username, password)
|
||||
except SimpliSafeAPIException:
|
||||
_LOGGER.error("Failed to set up SimpliSafe")
|
||||
return
|
||||
|
||||
def logout(event):
|
||||
"""Logout of the SimpliSafe API."""
|
||||
hass.data[DOMAIN].logout()
|
||||
systems = []
|
||||
|
||||
hass.bus.listen(EVENT_HOMEASSISTANT_STOP, logout)
|
||||
for system in simplisafe.get_systems():
|
||||
systems.append(SimpliSafeAlarm(system, name, code))
|
||||
|
||||
add_entities(systems)
|
||||
|
||||
|
||||
class SimpliSafeAlarm(alarm.AlarmControlPanel):
|
||||
class SimpliSafeAlarm(AlarmControlPanel):
|
||||
"""Representation of a SimpliSafe alarm."""
|
||||
|
||||
def __init__(self, simplisafe, name, code):
|
||||
@@ -74,27 +65,37 @@ class SimpliSafeAlarm(alarm.AlarmControlPanel):
|
||||
self._name = name
|
||||
self._code = str(code) if code else None
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique ID."""
|
||||
return self.simplisafe.location_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
if self._name is not None:
|
||||
return self._name
|
||||
return 'Alarm {}'.format(self.simplisafe.location_id())
|
||||
return 'Alarm {}'.format(self.simplisafe.location_id)
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters if code is defined."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
if isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
status = self.simplisafe.state()
|
||||
if status == 'off':
|
||||
status = self.simplisafe.state
|
||||
if status.lower() == 'off':
|
||||
state = STATE_ALARM_DISARMED
|
||||
elif status == 'home':
|
||||
elif status.lower() == 'home' or status.lower() == 'home_count':
|
||||
state = STATE_ALARM_ARMED_HOME
|
||||
elif status == 'away':
|
||||
elif (status.lower() == 'away' or status.lower() == 'exitDelay' or
|
||||
status.lower() == 'away_count'):
|
||||
state = STATE_ALARM_ARMED_AWAY
|
||||
else:
|
||||
state = STATE_UNKNOWN
|
||||
@@ -103,14 +104,13 @@ class SimpliSafeAlarm(alarm.AlarmControlPanel):
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
'alarm': self.simplisafe.alarm(),
|
||||
'co': self.simplisafe.carbon_monoxide(),
|
||||
'fire': self.simplisafe.fire(),
|
||||
'flood': self.simplisafe.flood(),
|
||||
'last_event': self.simplisafe.last_event(),
|
||||
'temperature': self.simplisafe.temperature(),
|
||||
}
|
||||
attributes = {}
|
||||
|
||||
attributes[ATTR_ALARM_ACTIVE] = self.simplisafe.alarm_active
|
||||
if self.simplisafe.temperature is not None:
|
||||
attributes[ATTR_TEMPERATURE] = self.simplisafe.temperature
|
||||
|
||||
return attributes
|
||||
|
||||
def update(self):
|
||||
"""Update alarm status."""
|
||||
|
||||
@@ -4,70 +4,65 @@ Support for Vanderbilt (formerly Siemens) SPC alarm systems.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.spc/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.spc import (
|
||||
ATTR_DISCOVER_AREAS, DATA_API, DATA_REGISTRY, SpcWebGateway)
|
||||
ATTR_DISCOVER_AREAS, DATA_API, SIGNAL_UPDATE_ALARM)
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
|
||||
STATE_UNKNOWN)
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SPC_AREA_MODE_TO_STATE = {
|
||||
'0': STATE_ALARM_DISARMED,
|
||||
'1': STATE_ALARM_ARMED_HOME,
|
||||
'3': STATE_ALARM_ARMED_AWAY,
|
||||
}
|
||||
|
||||
|
||||
def _get_alarm_state(spc_mode):
|
||||
def _get_alarm_state(area):
|
||||
"""Get the alarm state."""
|
||||
return SPC_AREA_MODE_TO_STATE.get(spc_mode, STATE_UNKNOWN)
|
||||
from pyspcwebgw.const import AreaMode
|
||||
|
||||
if area.verified_alarm:
|
||||
return STATE_ALARM_TRIGGERED
|
||||
|
||||
mode_to_state = {
|
||||
AreaMode.UNSET: STATE_ALARM_DISARMED,
|
||||
AreaMode.PART_SET_A: STATE_ALARM_ARMED_HOME,
|
||||
AreaMode.PART_SET_B: STATE_ALARM_ARMED_NIGHT,
|
||||
AreaMode.FULL_SET: STATE_ALARM_ARMED_AWAY,
|
||||
}
|
||||
return mode_to_state.get(area.mode)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up the SPC alarm control panel platform."""
|
||||
if (discovery_info is None or
|
||||
discovery_info[ATTR_DISCOVER_AREAS] is None):
|
||||
return
|
||||
|
||||
api = hass.data[DATA_API]
|
||||
devices = [SpcAlarm(api, area)
|
||||
for area in discovery_info[ATTR_DISCOVER_AREAS]]
|
||||
|
||||
async_add_devices(devices)
|
||||
async_add_entities([SpcAlarm(area=area, api=hass.data[DATA_API])
|
||||
for area in discovery_info[ATTR_DISCOVER_AREAS]])
|
||||
|
||||
|
||||
class SpcAlarm(alarm.AlarmControlPanel):
|
||||
"""Representation of the SPC alarm panel."""
|
||||
|
||||
def __init__(self, api, area):
|
||||
def __init__(self, area, api):
|
||||
"""Initialize the SPC alarm panel."""
|
||||
self._area_id = area['id']
|
||||
self._name = area['name']
|
||||
self._state = _get_alarm_state(area['mode'])
|
||||
if self._state == STATE_ALARM_DISARMED:
|
||||
self._changed_by = area.get('last_unset_user_name', 'unknown')
|
||||
else:
|
||||
self._changed_by = area.get('last_set_user_name', 'unknown')
|
||||
self._area = area
|
||||
self._api = api
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self):
|
||||
"""Call for adding new entities."""
|
||||
self.hass.data[DATA_REGISTRY].register_alarm_device(
|
||||
self._area_id, self)
|
||||
async_dispatcher_connect(self.hass,
|
||||
SIGNAL_UPDATE_ALARM.format(self._area.id),
|
||||
self._update_callback)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update_from_spc(self, state, extra):
|
||||
"""Update the alarm panel with a new state."""
|
||||
self._state = state
|
||||
self._changed_by = extra.get('changed_by', 'unknown')
|
||||
self.async_schedule_update_ha_state()
|
||||
@callback
|
||||
def _update_callback(self):
|
||||
"""Call update method."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -77,32 +72,34 @@ class SpcAlarm(alarm.AlarmControlPanel):
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
return self._area.name
|
||||
|
||||
@property
|
||||
def changed_by(self):
|
||||
"""Return the user the last change was triggered by."""
|
||||
return self._changed_by
|
||||
return self._area.last_changed_by
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._state
|
||||
return _get_alarm_state(self._area)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_disarm(self, code=None):
|
||||
async def async_alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
yield from self._api.send_area_command(
|
||||
self._area_id, SpcWebGateway.AREA_COMMAND_UNSET)
|
||||
from pyspcwebgw.const import AreaMode
|
||||
self._api.change_mode(area=self._area, new_mode=AreaMode.UNSET)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_home(self, code=None):
|
||||
async def async_alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
yield from self._api.send_area_command(
|
||||
self._area_id, SpcWebGateway.AREA_COMMAND_PART_SET)
|
||||
from pyspcwebgw.const import AreaMode
|
||||
self._api.change_mode(area=self._area, new_mode=AreaMode.PART_SET_A)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_away(self, code=None):
|
||||
async def async_alarm_arm_night(self, code=None):
|
||||
"""Send arm home command."""
|
||||
from pyspcwebgw.const import AreaMode
|
||||
self._api.change_mode(area=self._area, new_mode=AreaMode.PART_SET_B)
|
||||
|
||||
async def async_alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
yield from self._api.send_area_command(
|
||||
self._area_id, SpcWebGateway.AREA_COMMAND_SET)
|
||||
from pyspcwebgw.const import AreaMode
|
||||
self._api.change_mode(area=self._area, new_mode=AreaMode.FULL_SET)
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||
|
||||
|
||||
REQUIREMENTS = ['total_connect_client==0.17']
|
||||
REQUIREMENTS = ['total_connect_client==0.18']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,14 +31,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up a TotalConnect control panel."""
|
||||
name = config.get(CONF_NAME)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
|
||||
total_connect = TotalConnect(name, username, password)
|
||||
add_devices([total_connect], True)
|
||||
add_entities([total_connect], True)
|
||||
|
||||
|
||||
class TotalConnect(alarm.AlarmControlPanel):
|
||||
|
||||
@@ -17,13 +17,13 @@ from homeassistant.const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Verisure platform."""
|
||||
alarms = []
|
||||
if int(hub.config.get(CONF_ALARM, 1)):
|
||||
hub.update_overview()
|
||||
alarms.append(VerisureAlarm())
|
||||
add_devices(alarms)
|
||||
add_entities(alarms)
|
||||
|
||||
|
||||
def set_arm_state(state, code=None):
|
||||
@@ -60,8 +60,8 @@ class VerisureAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the code format as regex."""
|
||||
return '^\\d{%s}$' % self._digits
|
||||
"""Return one or more digits/characters."""
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def changed_by(self):
|
||||
|
||||
@@ -20,7 +20,7 @@ DEPENDENCIES = ['wink']
|
||||
STATE_ALARM_PRIVACY = 'Private'
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Wink platform."""
|
||||
import pywink
|
||||
|
||||
@@ -32,7 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
except AttributeError:
|
||||
_id = camera.object_id() + camera.name()
|
||||
if _id not in hass.data[DOMAIN]['unique_ids']:
|
||||
add_devices([WinkCameraDevice(camera, hass)])
|
||||
add_entities([WinkCameraDevice(camera, hass)])
|
||||
|
||||
|
||||
class WinkCameraDevice(WinkDevice, alarm.AlarmControlPanel):
|
||||
|
||||
98
homeassistant/components/alarm_control_panel/yale_smart_alarm.py
Executable file
98
homeassistant/components/alarm_control_panel/yale_smart_alarm.py
Executable file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Yale Smart Alarm client for interacting with the Yale Smart Alarm System API.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://www.home-assistant.io/components/alarm_control_panel.yale_smart_alarm
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanel, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import (
|
||||
CONF_PASSWORD, CONF_USERNAME, CONF_NAME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['yalesmartalarmclient==0.1.4']
|
||||
|
||||
CONF_AREA_ID = 'area_id'
|
||||
|
||||
DEFAULT_NAME = 'Yale Smart Alarm'
|
||||
|
||||
DEFAULT_AREA_ID = '1'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_AREA_ID, default=DEFAULT_AREA_ID): cv.string,
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the alarm platform."""
|
||||
name = config[CONF_NAME]
|
||||
username = config[CONF_USERNAME]
|
||||
password = config[CONF_PASSWORD]
|
||||
area_id = config[CONF_AREA_ID]
|
||||
|
||||
from yalesmartalarmclient.client import (
|
||||
YaleSmartAlarmClient, AuthenticationError)
|
||||
try:
|
||||
client = YaleSmartAlarmClient(username, password, area_id)
|
||||
except AuthenticationError:
|
||||
_LOGGER.error("Authentication failed. Check credentials")
|
||||
return
|
||||
|
||||
add_entities([YaleAlarmDevice(name, client)], True)
|
||||
|
||||
|
||||
class YaleAlarmDevice(AlarmControlPanel):
|
||||
"""Represent a Yale Smart Alarm."""
|
||||
|
||||
def __init__(self, name, client):
|
||||
"""Initialize the Yale Alarm Device."""
|
||||
self._name = name
|
||||
self._client = client
|
||||
self._state = None
|
||||
|
||||
from yalesmartalarmclient.client import (YALE_STATE_DISARM,
|
||||
YALE_STATE_ARM_PARTIAL,
|
||||
YALE_STATE_ARM_FULL)
|
||||
self._state_map = {
|
||||
YALE_STATE_DISARM: STATE_ALARM_DISARMED,
|
||||
YALE_STATE_ARM_PARTIAL: STATE_ALARM_ARMED_HOME,
|
||||
YALE_STATE_ARM_FULL: STATE_ALARM_ARMED_AWAY
|
||||
}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._state
|
||||
|
||||
def update(self):
|
||||
"""Return the state of the device."""
|
||||
armed_status = self._client.get_armed_status()
|
||||
|
||||
self._state = self._state_map.get(armed_status)
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
self._client.disarm()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
self._client.arm_partial()
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
self._client.arm_full()
|
||||
@@ -34,6 +34,8 @@ CONF_ZONE_NAME = 'name'
|
||||
CONF_ZONE_TYPE = 'type'
|
||||
CONF_ZONE_RFID = 'rfid'
|
||||
CONF_ZONES = 'zones'
|
||||
CONF_RELAY_ADDR = 'relayaddr'
|
||||
CONF_RELAY_CHAN = 'relaychan'
|
||||
|
||||
DEFAULT_DEVICE_TYPE = 'socket'
|
||||
DEFAULT_DEVICE_HOST = 'localhost'
|
||||
@@ -53,6 +55,7 @@ SIGNAL_PANEL_DISARM = 'alarmdecoder.panel_disarm'
|
||||
SIGNAL_ZONE_FAULT = 'alarmdecoder.zone_fault'
|
||||
SIGNAL_ZONE_RESTORE = 'alarmdecoder.zone_restore'
|
||||
SIGNAL_RFX_MESSAGE = 'alarmdecoder.rfx_message'
|
||||
SIGNAL_REL_MESSAGE = 'alarmdecoder.rel_message'
|
||||
|
||||
DEVICE_SOCKET_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_DEVICE_TYPE): 'socket',
|
||||
@@ -71,7 +74,11 @@ ZONE_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_ZONE_NAME): cv.string,
|
||||
vol.Optional(CONF_ZONE_TYPE,
|
||||
default=DEFAULT_ZONE_TYPE): vol.Any(DEVICE_CLASSES_SCHEMA),
|
||||
vol.Optional(CONF_ZONE_RFID): cv.string})
|
||||
vol.Optional(CONF_ZONE_RFID): cv.string,
|
||||
vol.Inclusive(CONF_RELAY_ADDR, 'relaylocation',
|
||||
'Relay address and channel must exist together'): cv.byte,
|
||||
vol.Inclusive(CONF_RELAY_CHAN, 'relaylocation',
|
||||
'Relay address and channel must exist together'): cv.byte})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
@@ -153,6 +160,11 @@ def setup(hass, config):
|
||||
hass.helpers.dispatcher.dispatcher_send(
|
||||
SIGNAL_ZONE_RESTORE, zone)
|
||||
|
||||
def handle_rel_message(sender, message):
|
||||
"""Handle relay message from AlarmDecoder."""
|
||||
hass.helpers.dispatcher.dispatcher_send(
|
||||
SIGNAL_REL_MESSAGE, message)
|
||||
|
||||
controller = False
|
||||
if device_type == 'socket':
|
||||
host = device.get(CONF_DEVICE_HOST)
|
||||
@@ -171,6 +183,7 @@ def setup(hass, config):
|
||||
controller.on_zone_fault += zone_fault_callback
|
||||
controller.on_zone_restore += zone_restore_callback
|
||||
controller.on_close += handle_closed_connection
|
||||
controller.on_relay_changed += handle_rel_message
|
||||
|
||||
hass.data[DATA_AD] = controller
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ def turn_on(hass, entity_id):
|
||||
def async_turn_on(hass, entity_id):
|
||||
"""Async reset the alert."""
|
||||
data = {ATTR_ENTITY_ID: entity_id}
|
||||
hass.async_add_job(
|
||||
hass.async_create_task(
|
||||
hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data))
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ def turn_off(hass, entity_id):
|
||||
def async_turn_off(hass, entity_id):
|
||||
"""Async acknowledge the alert."""
|
||||
data = {ATTR_ENTITY_ID: entity_id}
|
||||
hass.async_add_job(
|
||||
hass.async_create_task(
|
||||
hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data))
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ def toggle(hass, entity_id):
|
||||
def async_toggle(hass, entity_id):
|
||||
"""Async toggle acknowledgement of alert."""
|
||||
data = {ATTR_ENTITY_ID: entity_id}
|
||||
hass.async_add_job(
|
||||
hass.async_create_task(
|
||||
hass.services.async_call(DOMAIN, SERVICE_TOGGLE, data))
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ def async_setup(hass, config):
|
||||
|
||||
for alert_id in alert_ids:
|
||||
alert = all_alerts[alert_id]
|
||||
alert.async_set_context(service_call.context)
|
||||
if service_call.service == SERVICE_TURN_ON:
|
||||
yield from alert.async_turn_on()
|
||||
elif service_call.service == SERVICE_TOGGLE:
|
||||
@@ -217,7 +218,7 @@ class Alert(ToggleEntity):
|
||||
else:
|
||||
yield from self._schedule_notify()
|
||||
|
||||
self.hass.async_add_job(self.async_update_ha_state)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def end_alerting(self):
|
||||
@@ -228,7 +229,7 @@ class Alert(ToggleEntity):
|
||||
self._firing = False
|
||||
if self._done_message and self._send_done_message:
|
||||
yield from self._notify_done_message()
|
||||
self.hass.async_add_job(self.async_update_ha_state)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def _schedule_notify(self):
|
||||
|
||||
@@ -210,7 +210,7 @@ def resolve_slot_synonyms(key, request):
|
||||
return resolved_value
|
||||
|
||||
|
||||
class AlexaResponse(object):
|
||||
class AlexaResponse:
|
||||
"""Help generating the response for Alexa."""
|
||||
|
||||
def __init__(self, hass, intent_info):
|
||||
|
||||
@@ -13,12 +13,13 @@ import homeassistant.util.color as color_util
|
||||
from homeassistant.util.temperature import convert as convert_temperature
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, CONF_NAME,
|
||||
SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
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,
|
||||
SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
SERVICE_UNLOCK, SERVICE_VOLUME_SET, TEMP_FAHRENHEIT, TEMP_CELSIUS,
|
||||
CONF_UNIT_OF_MEASUREMENT, STATE_LOCKED, STATE_UNLOCKED, STATE_ON)
|
||||
STATE_LOCKED, STATE_UNLOCKED, STATE_ON)
|
||||
|
||||
from .const import CONF_FILTER, CONF_ENTITY_CONFIG
|
||||
|
||||
@@ -53,9 +54,10 @@ CONF_DISPLAY_CATEGORIES = 'display_categories'
|
||||
|
||||
HANDLERS = Registry()
|
||||
ENTITY_ADAPTERS = Registry()
|
||||
EVENT_ALEXA_SMART_HOME = 'alexa_smart_home'
|
||||
|
||||
|
||||
class _DisplayCategory(object):
|
||||
class _DisplayCategory:
|
||||
"""Possible display categories for Discovery response.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories
|
||||
@@ -107,7 +109,6 @@ class _DisplayCategory(object):
|
||||
THERMOSTAT = "THERMOSTAT"
|
||||
|
||||
# Indicates the endpoint is a television.
|
||||
# pylint: disable=invalid-name
|
||||
TV = "TV"
|
||||
|
||||
|
||||
@@ -154,13 +155,14 @@ class _UnsupportedProperty(Exception):
|
||||
"""This entity does not support the requested Smart Home API property."""
|
||||
|
||||
|
||||
class _AlexaEntity(object):
|
||||
class _AlexaEntity:
|
||||
"""An adaptation of an entity, expressed in Alexa's terms.
|
||||
|
||||
The API handlers should manipulate entities only through this interface.
|
||||
"""
|
||||
|
||||
def __init__(self, config, entity):
|
||||
def __init__(self, hass, config, entity):
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
self.entity = entity
|
||||
self.entity_conf = config.entity_config.get(entity.entity_id, {})
|
||||
@@ -209,7 +211,7 @@ class _AlexaEntity(object):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _AlexaInterface(object):
|
||||
class _AlexaInterface:
|
||||
def __init__(self, entity):
|
||||
self.entity = entity
|
||||
|
||||
@@ -271,11 +273,14 @@ class _AlexaInterface(object):
|
||||
"""Return properties serialized for an API response."""
|
||||
for prop in self.properties_supported():
|
||||
prop_name = prop['name']
|
||||
yield {
|
||||
'name': prop_name,
|
||||
'namespace': self.name(),
|
||||
'value': self.get_property(prop_name),
|
||||
}
|
||||
# pylint: disable=assignment-from-no-return
|
||||
prop_value = self.get_property(prop_name)
|
||||
if prop_value is not None:
|
||||
yield {
|
||||
'name': prop_name,
|
||||
'namespace': self.name(),
|
||||
'value': prop_value,
|
||||
}
|
||||
|
||||
|
||||
class _AlexaPowerController(_AlexaInterface):
|
||||
@@ -313,7 +318,7 @@ class _AlexaLockController(_AlexaInterface):
|
||||
|
||||
if self.entity.state == STATE_LOCKED:
|
||||
return 'LOCKED'
|
||||
elif self.entity.state == STATE_UNLOCKED:
|
||||
if self.entity.state == STATE_UNLOCKED:
|
||||
return 'UNLOCKED'
|
||||
return 'JAMMED'
|
||||
|
||||
@@ -381,6 +386,10 @@ class _AlexaInputController(_AlexaInterface):
|
||||
|
||||
|
||||
class _AlexaTemperatureSensor(_AlexaInterface):
|
||||
def __init__(self, hass, entity):
|
||||
_AlexaInterface.__init__(self, entity)
|
||||
self.hass = hass
|
||||
|
||||
def name(self):
|
||||
return 'Alexa.TemperatureSensor'
|
||||
|
||||
@@ -394,9 +403,10 @@ class _AlexaTemperatureSensor(_AlexaInterface):
|
||||
if name != 'temperature':
|
||||
raise _UnsupportedProperty(name)
|
||||
|
||||
unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
temp = self.entity.state
|
||||
if self.entity.domain == climate.DOMAIN:
|
||||
unit = self.hass.config.units.temperature_unit
|
||||
temp = self.entity.attributes.get(
|
||||
climate.ATTR_CURRENT_TEMPERATURE)
|
||||
return {
|
||||
@@ -406,6 +416,10 @@ class _AlexaTemperatureSensor(_AlexaInterface):
|
||||
|
||||
|
||||
class _AlexaThermostatController(_AlexaInterface):
|
||||
def __init__(self, hass, entity):
|
||||
_AlexaInterface.__init__(self, entity)
|
||||
self.hass = hass
|
||||
|
||||
def name(self):
|
||||
return 'Alexa.ThermostatController'
|
||||
|
||||
@@ -436,17 +450,19 @@ class _AlexaThermostatController(_AlexaInterface):
|
||||
raise _UnsupportedProperty(name)
|
||||
return mode
|
||||
|
||||
unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
temp = None
|
||||
unit = self.hass.config.units.temperature_unit
|
||||
if name == 'targetSetpoint':
|
||||
temp = self.entity.attributes.get(ATTR_TEMPERATURE)
|
||||
temp = self.entity.attributes.get(climate.ATTR_TEMPERATURE)
|
||||
elif name == 'lowerSetpoint':
|
||||
temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)
|
||||
elif name == 'upperSetpoint':
|
||||
temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)
|
||||
if temp is None:
|
||||
else:
|
||||
raise _UnsupportedProperty(name)
|
||||
|
||||
if temp is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
'value': float(temp),
|
||||
'scale': API_TEMP_UNITS[unit],
|
||||
@@ -485,8 +501,8 @@ class _ClimateCapabilities(_AlexaEntity):
|
||||
return [_DisplayCategory.THERMOSTAT]
|
||||
|
||||
def interfaces(self):
|
||||
yield _AlexaThermostatController(self.entity)
|
||||
yield _AlexaTemperatureSensor(self.entity)
|
||||
yield _AlexaThermostatController(self.hass, self.entity)
|
||||
yield _AlexaTemperatureSensor(self.hass, self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(cover.DOMAIN)
|
||||
@@ -603,14 +619,14 @@ class _SensorCapabilities(_AlexaEntity):
|
||||
|
||||
def interfaces(self):
|
||||
attrs = self.entity.attributes
|
||||
if attrs.get(CONF_UNIT_OF_MEASUREMENT) in (
|
||||
if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in (
|
||||
TEMP_FAHRENHEIT,
|
||||
TEMP_CELSIUS,
|
||||
):
|
||||
yield _AlexaTemperatureSensor(self.entity)
|
||||
yield _AlexaTemperatureSensor(self.hass, self.entity)
|
||||
|
||||
|
||||
class _Cause(object):
|
||||
class _Cause:
|
||||
"""Possible causes for property changes.
|
||||
|
||||
https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object
|
||||
@@ -698,24 +714,47 @@ class SmartHomeView(http.HomeAssistantView):
|
||||
return b'' if response is None else self.json(response)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, config, message):
|
||||
async def async_handle_message(hass, config, request, context=None):
|
||||
"""Handle incoming API messages."""
|
||||
assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3'
|
||||
assert request[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3'
|
||||
|
||||
if context is None:
|
||||
context = ha.Context()
|
||||
|
||||
# Read head data
|
||||
message = message[API_DIRECTIVE]
|
||||
namespace = message[API_HEADER]['namespace']
|
||||
name = message[API_HEADER]['name']
|
||||
request = request[API_DIRECTIVE]
|
||||
namespace = request[API_HEADER]['namespace']
|
||||
name = request[API_HEADER]['name']
|
||||
|
||||
# Do we support this API request?
|
||||
funct_ref = HANDLERS.get((namespace, name))
|
||||
if not funct_ref:
|
||||
if funct_ref:
|
||||
response = await funct_ref(hass, config, request, context)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Unsupported API request %s/%s", namespace, name)
|
||||
return api_error(message)
|
||||
response = api_error(request)
|
||||
|
||||
return (yield from funct_ref(hass, config, message))
|
||||
request_info = {
|
||||
'namespace': namespace,
|
||||
'name': name,
|
||||
}
|
||||
|
||||
if API_ENDPOINT in request and 'endpointId' in request[API_ENDPOINT]:
|
||||
request_info['entity_id'] = \
|
||||
request[API_ENDPOINT]['endpointId'].replace('#', '.')
|
||||
|
||||
response_header = response[API_EVENT][API_HEADER]
|
||||
|
||||
hass.bus.async_fire(EVENT_ALEXA_SMART_HOME, {
|
||||
'request': request_info,
|
||||
'response': {
|
||||
'namespace': response_header['namespace'],
|
||||
'name': response_header['name'],
|
||||
}
|
||||
}, context=context)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def api_message(request,
|
||||
@@ -779,8 +818,7 @@ def api_error(request,
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Discovery', 'Discover'))
|
||||
@asyncio.coroutine
|
||||
def async_api_discovery(hass, config, request):
|
||||
async def async_api_discovery(hass, config, request, context):
|
||||
"""Create a API formatted discovery response.
|
||||
|
||||
Async friendly.
|
||||
@@ -795,7 +833,7 @@ def async_api_discovery(hass, config, request):
|
||||
|
||||
if entity.domain not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
alexa_entity = ENTITY_ADAPTERS[entity.domain](config, entity)
|
||||
alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity)
|
||||
|
||||
endpoint = {
|
||||
'displayCategories': alexa_entity.display_categories(),
|
||||
@@ -822,8 +860,7 @@ def async_api_discovery(hass, config, request):
|
||||
|
||||
def extract_entity(funct):
|
||||
"""Decorate for extract entity object from request."""
|
||||
@asyncio.coroutine
|
||||
def async_api_entity_wrapper(hass, config, request):
|
||||
async def async_api_entity_wrapper(hass, config, request, context):
|
||||
"""Process a turn on request."""
|
||||
entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.')
|
||||
|
||||
@@ -834,15 +871,14 @@ def extract_entity(funct):
|
||||
request[API_HEADER]['name'], entity_id)
|
||||
return api_error(request, error_type='NO_SUCH_ENDPOINT')
|
||||
|
||||
return (yield from funct(hass, config, request, entity))
|
||||
return await funct(hass, config, request, context, entity)
|
||||
|
||||
return async_api_entity_wrapper
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PowerController', 'TurnOn'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_turn_on(hass, config, request, entity):
|
||||
async def async_api_turn_on(hass, config, request, context, entity):
|
||||
"""Process a turn on request."""
|
||||
domain = entity.domain
|
||||
if entity.domain == group.DOMAIN:
|
||||
@@ -852,17 +888,16 @@ def async_api_turn_on(hass, config, request, entity):
|
||||
if entity.domain == cover.DOMAIN:
|
||||
service = cover.SERVICE_OPEN_COVER
|
||||
|
||||
yield from hass.services.async_call(domain, service, {
|
||||
await hass.services.async_call(domain, service, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PowerController', 'TurnOff'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_turn_off(hass, config, request, entity):
|
||||
async def async_api_turn_off(hass, config, request, context, entity):
|
||||
"""Process a turn off request."""
|
||||
domain = entity.domain
|
||||
if entity.domain == group.DOMAIN:
|
||||
@@ -872,32 +907,30 @@ def async_api_turn_off(hass, config, request, entity):
|
||||
if entity.domain == cover.DOMAIN:
|
||||
service = cover.SERVICE_CLOSE_COVER
|
||||
|
||||
yield from hass.services.async_call(domain, service, {
|
||||
await hass.services.async_call(domain, service, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_brightness(hass, config, request, entity):
|
||||
async def async_api_set_brightness(hass, config, request, context, entity):
|
||||
"""Process a set brightness request."""
|
||||
brightness = int(request[API_PAYLOAD]['brightness'])
|
||||
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: brightness,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_brightness(hass, config, request, entity):
|
||||
async def async_api_adjust_brightness(hass, config, request, context, entity):
|
||||
"""Process an adjust brightness request."""
|
||||
brightness_delta = int(request[API_PAYLOAD]['brightnessDelta'])
|
||||
|
||||
@@ -910,18 +943,17 @@ def async_api_adjust_brightness(hass, config, request, entity):
|
||||
|
||||
# set brightness
|
||||
brightness = max(0, brightness_delta + current)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: brightness,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ColorController', 'SetColor'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_color(hass, config, request, entity):
|
||||
async def async_api_set_color(hass, config, request, context, entity):
|
||||
"""Process a set color request."""
|
||||
rgb = color_util.color_hsb_to_RGB(
|
||||
float(request[API_PAYLOAD]['color']['hue']),
|
||||
@@ -929,25 +961,25 @@ def async_api_set_color(hass, config, request, entity):
|
||||
float(request[API_PAYLOAD]['color']['brightness'])
|
||||
)
|
||||
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_RGB_COLOR: rgb,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_color_temperature(hass, config, request, entity):
|
||||
async def async_api_set_color_temperature(hass, config, request, context,
|
||||
entity):
|
||||
"""Process a set color temperature request."""
|
||||
kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin'])
|
||||
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_KELVIN: kelvin,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
@@ -955,17 +987,17 @@ def async_api_set_color_temperature(hass, config, request, entity):
|
||||
@HANDLERS.register(
|
||||
('Alexa.ColorTemperatureController', 'DecreaseColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_decrease_color_temp(hass, config, request, entity):
|
||||
async def async_api_decrease_color_temp(hass, config, request, context,
|
||||
entity):
|
||||
"""Process a decrease color temperature request."""
|
||||
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
||||
max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS))
|
||||
|
||||
value = min(max_mireds, current + 50)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_COLOR_TEMP: value,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
@@ -973,31 +1005,30 @@ def async_api_decrease_color_temp(hass, config, request, entity):
|
||||
@HANDLERS.register(
|
||||
('Alexa.ColorTemperatureController', 'IncreaseColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_increase_color_temp(hass, config, request, entity):
|
||||
async def async_api_increase_color_temp(hass, config, request, context,
|
||||
entity):
|
||||
"""Process an increase color temperature request."""
|
||||
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
||||
min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
|
||||
|
||||
value = max(min_mireds, current - 50)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_COLOR_TEMP: value,
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.SceneController', 'Activate'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_activate(hass, config, request, entity):
|
||||
async def async_api_activate(hass, config, request, context, entity):
|
||||
"""Process an activate request."""
|
||||
domain = entity.domain
|
||||
|
||||
yield from hass.services.async_call(domain, SERVICE_TURN_ON, {
|
||||
await hass.services.async_call(domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
payload = {
|
||||
'cause': {'type': _Cause.VOICE_INTERACTION},
|
||||
@@ -1014,14 +1045,13 @@ def async_api_activate(hass, config, request, entity):
|
||||
|
||||
@HANDLERS.register(('Alexa.SceneController', 'Deactivate'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_deactivate(hass, config, request, entity):
|
||||
async def async_api_deactivate(hass, config, request, context, entity):
|
||||
"""Process a deactivate request."""
|
||||
domain = entity.domain
|
||||
|
||||
yield from hass.services.async_call(domain, SERVICE_TURN_OFF, {
|
||||
await hass.services.async_call(domain, SERVICE_TURN_OFF, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
payload = {
|
||||
'cause': {'type': _Cause.VOICE_INTERACTION},
|
||||
@@ -1038,8 +1068,7 @@ def async_api_deactivate(hass, config, request, entity):
|
||||
|
||||
@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_percentage(hass, config, request, entity):
|
||||
async def async_api_set_percentage(hass, config, request, context, entity):
|
||||
"""Process a set percentage request."""
|
||||
percentage = int(request[API_PAYLOAD]['percentage'])
|
||||
service = None
|
||||
@@ -1061,16 +1090,15 @@ def async_api_set_percentage(hass, config, request, entity):
|
||||
service = SERVICE_SET_COVER_POSITION
|
||||
data[cover.ATTR_POSITION] = percentage
|
||||
|
||||
yield from hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False)
|
||||
await hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_percentage(hass, config, request, entity):
|
||||
async def async_api_adjust_percentage(hass, config, request, context, entity):
|
||||
"""Process an adjust percentage request."""
|
||||
percentage_delta = int(request[API_PAYLOAD]['percentageDelta'])
|
||||
service = None
|
||||
@@ -1109,20 +1137,19 @@ def async_api_adjust_percentage(hass, config, request, entity):
|
||||
|
||||
data[cover.ATTR_POSITION] = max(0, percentage_delta + current)
|
||||
|
||||
yield from hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False)
|
||||
await hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.LockController', 'Lock'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_lock(hass, config, request, entity):
|
||||
async def async_api_lock(hass, config, request, context, entity):
|
||||
"""Process a lock request."""
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_LOCK, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_LOCK, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
# Alexa expects a lockState in the response, we don't know the actual
|
||||
# lockState at this point but assume it is locked. It is reported
|
||||
@@ -1139,20 +1166,18 @@ def async_api_lock(hass, config, request, entity):
|
||||
# Not supported by Alexa yet
|
||||
@HANDLERS.register(('Alexa.LockController', 'Unlock'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_unlock(hass, config, request, entity):
|
||||
async def async_api_unlock(hass, config, request, context, entity):
|
||||
"""Process an unlock request."""
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_UNLOCK, {
|
||||
await hass.services.async_call(entity.domain, SERVICE_UNLOCK, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False)
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Speaker', 'SetVolume'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_volume(hass, config, request, entity):
|
||||
async def async_api_set_volume(hass, config, request, context, entity):
|
||||
"""Process a set volume request."""
|
||||
volume = round(float(request[API_PAYLOAD]['volume'] / 100), 2)
|
||||
|
||||
@@ -1161,17 +1186,16 @@ def async_api_set_volume(hass, config, request, entity):
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_VOLUME_SET,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.InputController', 'SelectInput'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_select_input(hass, config, request, entity):
|
||||
async def async_api_select_input(hass, config, request, context, entity):
|
||||
"""Process a set input request."""
|
||||
media_input = request[API_PAYLOAD]['input']
|
||||
|
||||
@@ -1195,17 +1219,16 @@ def async_api_select_input(hass, config, request, entity):
|
||||
media_player.ATTR_INPUT_SOURCE: media_input,
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_SELECT_SOURCE,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_volume(hass, config, request, entity):
|
||||
async def async_api_adjust_volume(hass, config, request, context, entity):
|
||||
"""Process an adjust volume request."""
|
||||
volume_delta = int(request[API_PAYLOAD]['volume'])
|
||||
|
||||
@@ -1224,17 +1247,16 @@ def async_api_adjust_volume(hass, config, request, entity):
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_VOLUME_SET,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_volume_step(hass, config, request, entity):
|
||||
async def async_api_adjust_volume_step(hass, config, request, context, entity):
|
||||
"""Process an adjust volume step request."""
|
||||
# media_player volume up/down service does not support specifying steps
|
||||
# each component handles it differently e.g. via config.
|
||||
@@ -1247,13 +1269,13 @@ def async_api_adjust_volume_step(hass, config, request, entity):
|
||||
}
|
||||
|
||||
if volume_step > 0:
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_VOLUME_UP,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
elif volume_step < 0:
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_VOLUME_DOWN,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
@@ -1261,8 +1283,7 @@ def async_api_adjust_volume_step(hass, config, request, entity):
|
||||
@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute'))
|
||||
@HANDLERS.register(('Alexa.Speaker', 'SetMute'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_mute(hass, config, request, entity):
|
||||
async def async_api_set_mute(hass, config, request, context, entity):
|
||||
"""Process a set mute request."""
|
||||
mute = bool(request[API_PAYLOAD]['mute'])
|
||||
|
||||
@@ -1271,98 +1292,94 @@ def async_api_set_mute(hass, config, request, entity):
|
||||
media_player.ATTR_MEDIA_VOLUME_MUTED: mute,
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_VOLUME_MUTE,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Play'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_play(hass, config, request, entity):
|
||||
async def async_api_play(hass, config, request, context, entity):
|
||||
"""Process a play request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PLAY,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Pause'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_pause(hass, config, request, entity):
|
||||
async def async_api_pause(hass, config, request, context, entity):
|
||||
"""Process a pause request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PAUSE,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Stop'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_stop(hass, config, request, entity):
|
||||
async def async_api_stop(hass, config, request, context, entity):
|
||||
"""Process a stop request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_STOP,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Next'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_next(hass, config, request, entity):
|
||||
async def async_api_next(hass, config, request, context, entity):
|
||||
"""Process a next request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_NEXT_TRACK,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Previous'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_previous(hass, config, request, entity):
|
||||
async def async_api_previous(hass, config, request, context, entity):
|
||||
"""Process a previous request."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
data, blocking=False)
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
def api_error_temp_range(request, temp, min_temp, max_temp, unit):
|
||||
def api_error_temp_range(hass, request, temp, min_temp, max_temp):
|
||||
"""Create temperature value out of range API error response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
unit = hass.config.units.temperature_unit
|
||||
temp_range = {
|
||||
'minimumValue': {
|
||||
'value': min_temp,
|
||||
@@ -1383,8 +1400,9 @@ def api_error_temp_range(request, temp, min_temp, max_temp, unit):
|
||||
)
|
||||
|
||||
|
||||
def temperature_from_object(temp_obj, to_unit, interval=False):
|
||||
def temperature_from_object(hass, temp_obj, interval=False):
|
||||
"""Get temperature from Temperature object in requested unit."""
|
||||
to_unit = hass.config.units.temperature_unit
|
||||
from_unit = TEMP_CELSIUS
|
||||
temp = float(temp_obj['value'])
|
||||
|
||||
@@ -1400,9 +1418,8 @@ def temperature_from_object(temp_obj, to_unit, interval=False):
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature'))
|
||||
@extract_entity
|
||||
async def async_api_set_target_temp(hass, config, request, entity):
|
||||
async def async_api_set_target_temp(hass, config, request, context, entity):
|
||||
"""Process a set target temperature request."""
|
||||
unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
||||
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
||||
|
||||
@@ -1412,48 +1429,45 @@ async def async_api_set_target_temp(hass, config, request, entity):
|
||||
|
||||
payload = request[API_PAYLOAD]
|
||||
if 'targetSetpoint' in payload:
|
||||
temp = temperature_from_object(
|
||||
payload['targetSetpoint'], unit)
|
||||
temp = temperature_from_object(hass, payload['targetSetpoint'])
|
||||
if temp < min_temp or temp > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp, min_temp, max_temp, unit)
|
||||
hass, request, temp, min_temp, max_temp)
|
||||
data[ATTR_TEMPERATURE] = temp
|
||||
if 'lowerSetpoint' in payload:
|
||||
temp_low = temperature_from_object(
|
||||
payload['lowerSetpoint'], unit)
|
||||
temp_low = temperature_from_object(hass, payload['lowerSetpoint'])
|
||||
if temp_low < min_temp or temp_low > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp_low, min_temp, max_temp, unit)
|
||||
hass, request, temp_low, min_temp, max_temp)
|
||||
data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
|
||||
if 'upperSetpoint' in payload:
|
||||
temp_high = temperature_from_object(
|
||||
payload['upperSetpoint'], unit)
|
||||
temp_high = temperature_from_object(hass, payload['upperSetpoint'])
|
||||
if temp_high < min_temp or temp_high > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp_high, min_temp, max_temp, unit)
|
||||
hass, request, temp_high, min_temp, max_temp)
|
||||
data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False)
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
|
||||
context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature'))
|
||||
@extract_entity
|
||||
async def async_api_adjust_target_temp(hass, config, request, entity):
|
||||
async def async_api_adjust_target_temp(hass, config, request, context, entity):
|
||||
"""Process an adjust target temperature request."""
|
||||
unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
||||
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
||||
|
||||
temp_delta = temperature_from_object(
|
||||
request[API_PAYLOAD]['targetSetpointDelta'], unit, interval=True)
|
||||
hass, request[API_PAYLOAD]['targetSetpointDelta'], interval=True)
|
||||
target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta
|
||||
|
||||
if target_temp < min_temp or target_temp > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, target_temp, min_temp, max_temp, unit)
|
||||
hass, request, target_temp, min_temp, max_temp)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
@@ -1461,22 +1475,21 @@ async def async_api_adjust_target_temp(hass, config, request, entity):
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False)
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
|
||||
context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode'))
|
||||
@extract_entity
|
||||
async def async_api_set_thermostat_mode(hass, config, request, entity):
|
||||
async def async_api_set_thermostat_mode(hass, config, request, context,
|
||||
entity):
|
||||
"""Process a set thermostat mode request."""
|
||||
mode = request[API_PAYLOAD]['thermostatMode']
|
||||
mode = mode if isinstance(mode, str) else mode['value']
|
||||
|
||||
operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST)
|
||||
# Work around a pylint false positive due to
|
||||
# https://github.com/PyCQA/pylint/issues/1830
|
||||
# pylint: disable=stop-iteration-return
|
||||
ha_mode = next(
|
||||
(k for k, v in API_THERMOSTAT_MODES.items() if v == mode),
|
||||
None
|
||||
@@ -1497,17 +1510,16 @@ async def async_api_set_thermostat_mode(hass, config, request, entity):
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_OPERATION_MODE, data,
|
||||
blocking=False)
|
||||
blocking=False, context=context)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa', 'ReportState'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_reportstate(hass, config, request, entity):
|
||||
async def async_api_reportstate(hass, config, request, context, entity):
|
||||
"""Process a ReportState request."""
|
||||
alexa_entity = ENTITY_ADAPTERS[entity.domain](config, entity)
|
||||
alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity)
|
||||
properties = []
|
||||
for interface in alexa_entity.interfaces():
|
||||
properties.extend(interface.serialize_properties())
|
||||
@@ -1517,3 +1529,8 @@ def async_api_reportstate(hass, config, request, entity):
|
||||
name='StateReport',
|
||||
context={'properties': properties}
|
||||
)
|
||||
|
||||
|
||||
def turned_off_response(message):
|
||||
"""Return a device turned off response."""
|
||||
return api_error(message[API_DIRECTIVE], error_type='BRIDGE_UNREACHABLE')
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['amcrest==1.2.2']
|
||||
REQUIREMENTS = ['amcrest==1.2.3']
|
||||
DEPENDENCIES = ['ffmpeg']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -164,7 +164,7 @@ def setup(hass, config):
|
||||
return True
|
||||
|
||||
|
||||
class AmcrestDevice(object):
|
||||
class AmcrestDevice:
|
||||
"""Representation of a base Amcrest discovery device."""
|
||||
|
||||
def __init__(self, camera, name, authentication, ffmpeg_arguments,
|
||||
|
||||
@@ -214,11 +214,11 @@ def async_setup(hass, config):
|
||||
CONF_PASSWORD: password
|
||||
})
|
||||
|
||||
hass.async_add_job(discovery.async_load_platform(
|
||||
hass.async_create_task(discovery.async_load_platform(
|
||||
hass, 'camera', 'mjpeg', mjpeg_camera, config))
|
||||
|
||||
if sensors:
|
||||
hass.async_add_job(discovery.async_load_platform(
|
||||
hass.async_create_task(discovery.async_load_platform(
|
||||
hass, 'sensor', DOMAIN, {
|
||||
CONF_NAME: name,
|
||||
CONF_HOST: host,
|
||||
@@ -226,7 +226,7 @@ def async_setup(hass, config):
|
||||
}, config))
|
||||
|
||||
if switches:
|
||||
hass.async_add_job(discovery.async_load_platform(
|
||||
hass.async_create_task(discovery.async_load_platform(
|
||||
hass, 'switch', DOMAIN, {
|
||||
CONF_NAME: name,
|
||||
CONF_HOST: host,
|
||||
@@ -234,7 +234,7 @@ def async_setup(hass, config):
|
||||
}, config))
|
||||
|
||||
if motion:
|
||||
hass.async_add_job(discovery.async_load_platform(
|
||||
hass.async_create_task(discovery.async_load_platform(
|
||||
hass, 'binary_sensor', DOMAIN, {
|
||||
CONF_HOST: host,
|
||||
CONF_NAME: name,
|
||||
|
||||
@@ -58,7 +58,7 @@ def setup(hass, config):
|
||||
return True
|
||||
|
||||
|
||||
class APCUPSdData(object):
|
||||
class APCUPSdData:
|
||||
"""Stores the data retrieved from APCUPSd.
|
||||
|
||||
For each entity to use, acts as the single point responsible for fetching
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Rest API for Home Assistant.
|
||||
|
||||
For more details about the RESTful API, please refer to the documentation at
|
||||
https://home-assistant.io/developers/api/
|
||||
https://developers.home-assistant.io/docs/en/external_api_rest.html
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -11,31 +11,34 @@ import logging
|
||||
from aiohttp import web
|
||||
import async_timeout
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.bootstrap import DATA_LOGGING
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED,
|
||||
HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND,
|
||||
MATCH_ALL, URL_API, URL_API_COMPONENTS,
|
||||
URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG,
|
||||
URL_API_EVENTS, URL_API_SERVICES,
|
||||
URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE,
|
||||
__version__)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.state import AsyncTrackStates
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST,
|
||||
HTTP_CREATED, HTTP_NOT_FOUND, MATCH_ALL, URL_API, URL_API_COMPONENTS,
|
||||
URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG, URL_API_EVENTS,
|
||||
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.helpers import template
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
from homeassistant.helpers.state import AsyncTrackStates
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_BASE_URL = 'base_url'
|
||||
ATTR_LOCATION_NAME = 'location_name'
|
||||
ATTR_REQUIRES_API_PASSWORD = 'requires_api_password'
|
||||
ATTR_VERSION = 'version'
|
||||
|
||||
DOMAIN = 'api'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
STREAM_PING_PAYLOAD = "ping"
|
||||
STREAM_PING_PAYLOAD = 'ping'
|
||||
STREAM_PING_INTERVAL = 50 # seconds
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Register the API with the HTTP interface."""
|
||||
@@ -62,23 +65,22 @@ class APIStatusView(HomeAssistantView):
|
||||
"""View to handle Status requests."""
|
||||
|
||||
url = URL_API
|
||||
name = "api:status"
|
||||
name = 'api:status'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Retrieve if API is running."""
|
||||
return self.json_message('API running.')
|
||||
return self.json_message("API running.")
|
||||
|
||||
|
||||
class APIEventStream(HomeAssistantView):
|
||||
"""View to handle EventStream requests."""
|
||||
|
||||
url = URL_API_STREAM
|
||||
name = "api:stream"
|
||||
name = 'api:stream'
|
||||
|
||||
async def get(self, request):
|
||||
"""Provide a streaming interface for the event bus."""
|
||||
# pylint: disable=no-self-use
|
||||
hass = request.app['hass']
|
||||
stop_obj = object()
|
||||
to_write = asyncio.Queue(loop=hass.loop)
|
||||
@@ -95,12 +97,12 @@ class APIEventStream(HomeAssistantView):
|
||||
if restrict and event.event_type not in restrict:
|
||||
return
|
||||
|
||||
_LOGGER.debug('STREAM %s FORWARDING %s', id(stop_obj), event)
|
||||
_LOGGER.debug("STREAM %s FORWARDING %s", id(stop_obj), event)
|
||||
|
||||
if event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
data = stop_obj
|
||||
else:
|
||||
data = json.dumps(event, cls=rem.JSONEncoder)
|
||||
data = json.dumps(event, cls=JSONEncoder)
|
||||
|
||||
await to_write.put(data)
|
||||
|
||||
@@ -111,7 +113,7 @@ class APIEventStream(HomeAssistantView):
|
||||
unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events)
|
||||
|
||||
try:
|
||||
_LOGGER.debug('STREAM %s ATTACHED', id(stop_obj))
|
||||
_LOGGER.debug("STREAM %s ATTACHED", id(stop_obj))
|
||||
|
||||
# Fire off one message so browsers fire open event right away
|
||||
await to_write.put(STREAM_PING_PAYLOAD)
|
||||
@@ -126,25 +128,25 @@ class APIEventStream(HomeAssistantView):
|
||||
break
|
||||
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
_LOGGER.debug('STREAM %s WRITING %s', id(stop_obj),
|
||||
msg.strip())
|
||||
await response.write(msg.encode("UTF-8"))
|
||||
_LOGGER.debug(
|
||||
"STREAM %s WRITING %s", id(stop_obj), msg.strip())
|
||||
await response.write(msg.encode('UTF-8'))
|
||||
except asyncio.TimeoutError:
|
||||
await to_write.put(STREAM_PING_PAYLOAD)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug('STREAM %s ABORT', id(stop_obj))
|
||||
_LOGGER.debug("STREAM %s ABORT", id(stop_obj))
|
||||
|
||||
finally:
|
||||
_LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj))
|
||||
_LOGGER.debug("STREAM %s RESPONSE CLOSED", id(stop_obj))
|
||||
unsub_stream()
|
||||
|
||||
|
||||
class APIConfigView(HomeAssistantView):
|
||||
"""View to handle Config requests."""
|
||||
"""View to handle Configuration requests."""
|
||||
|
||||
url = URL_API_CONFIG
|
||||
name = "api:config"
|
||||
name = 'api:config'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
@@ -153,22 +155,22 @@ class APIConfigView(HomeAssistantView):
|
||||
|
||||
|
||||
class APIDiscoveryView(HomeAssistantView):
|
||||
"""View to provide discovery info."""
|
||||
"""View to provide Discovery information."""
|
||||
|
||||
requires_auth = False
|
||||
url = URL_API_DISCOVERY_INFO
|
||||
name = "api:discovery"
|
||||
name = 'api:discovery'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get discovery info."""
|
||||
"""Get discovery information."""
|
||||
hass = request.app['hass']
|
||||
needs_auth = hass.config.api.api_password is not None
|
||||
return self.json({
|
||||
'base_url': hass.config.api.base_url,
|
||||
'location_name': hass.config.location_name,
|
||||
'requires_api_password': needs_auth,
|
||||
'version': __version__
|
||||
ATTR_BASE_URL: hass.config.api.base_url,
|
||||
ATTR_LOCATION_NAME: hass.config.location_name,
|
||||
ATTR_REQUIRES_API_PASSWORD: needs_auth,
|
||||
ATTR_VERSION: __version__,
|
||||
})
|
||||
|
||||
|
||||
@@ -187,8 +189,8 @@ class APIStatesView(HomeAssistantView):
|
||||
class APIEntityStateView(HomeAssistantView):
|
||||
"""View to handle EntityState requests."""
|
||||
|
||||
url = "/api/states/{entity_id}"
|
||||
name = "api:entity-state"
|
||||
url = '/api/states/{entity_id}'
|
||||
name = 'api:entity-state'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request, entity_id):
|
||||
@@ -196,7 +198,7 @@ class APIEntityStateView(HomeAssistantView):
|
||||
state = request.app['hass'].states.get(entity_id)
|
||||
if state:
|
||||
return self.json(state)
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
return self.json_message("Entity not found.", HTTP_NOT_FOUND)
|
||||
|
||||
async def post(self, request, entity_id):
|
||||
"""Update state of entity."""
|
||||
@@ -204,13 +206,13 @@ class APIEntityStateView(HomeAssistantView):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON specified',
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Invalid JSON specified.", HTTP_BAD_REQUEST)
|
||||
|
||||
new_state = data.get('state')
|
||||
|
||||
if new_state is None:
|
||||
return self.json_message('No state specified', HTTP_BAD_REQUEST)
|
||||
return self.json_message("No state specified.", HTTP_BAD_REQUEST)
|
||||
|
||||
attributes = data.get('attributes')
|
||||
force_update = data.get('force_update', False)
|
||||
@@ -218,7 +220,8 @@ class APIEntityStateView(HomeAssistantView):
|
||||
is_new_state = hass.states.get(entity_id) is None
|
||||
|
||||
# Write state
|
||||
hass.states.async_set(entity_id, new_state, attributes, force_update)
|
||||
hass.states.async_set(entity_id, new_state, attributes, force_update,
|
||||
self.context(request))
|
||||
|
||||
# Read the state back for our response
|
||||
status_code = HTTP_CREATED if is_new_state else 200
|
||||
@@ -232,15 +235,15 @@ class APIEntityStateView(HomeAssistantView):
|
||||
def delete(self, request, entity_id):
|
||||
"""Remove entity."""
|
||||
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)
|
||||
return self.json_message("Entity removed.")
|
||||
return self.json_message("Entity not found.", HTTP_NOT_FOUND)
|
||||
|
||||
|
||||
class APIEventListenersView(HomeAssistantView):
|
||||
"""View to handle EventListeners requests."""
|
||||
|
||||
url = URL_API_EVENTS
|
||||
name = "api:event-listeners"
|
||||
name = 'api:event-listeners'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
@@ -252,7 +255,7 @@ class APIEventView(HomeAssistantView):
|
||||
"""View to handle Event requests."""
|
||||
|
||||
url = '/api/events/{event_type}'
|
||||
name = "api:event"
|
||||
name = 'api:event'
|
||||
|
||||
async def post(self, request, event_type):
|
||||
"""Fire events."""
|
||||
@@ -260,12 +263,12 @@ class APIEventView(HomeAssistantView):
|
||||
try:
|
||||
event_data = json.loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message('Event data should be valid JSON',
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Event data should be valid JSON.", HTTP_BAD_REQUEST)
|
||||
|
||||
if event_data is not None and not isinstance(event_data, dict):
|
||||
return self.json_message('Event data should be a JSON object',
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Event data should be a JSON object", HTTP_BAD_REQUEST)
|
||||
|
||||
# Special case handling for event STATE_CHANGED
|
||||
# We will try to convert state dicts back to State objects
|
||||
@@ -276,8 +279,9 @@ class APIEventView(HomeAssistantView):
|
||||
if state:
|
||||
event_data[key] = state
|
||||
|
||||
request.app['hass'].bus.async_fire(event_type, event_data,
|
||||
ha.EventOrigin.remote)
|
||||
request.app['hass'].bus.async_fire(
|
||||
event_type, event_data, ha.EventOrigin.remote,
|
||||
self.context(request))
|
||||
|
||||
return self.json_message("Event {} fired.".format(event_type))
|
||||
|
||||
@@ -286,7 +290,7 @@ class APIServicesView(HomeAssistantView):
|
||||
"""View to handle Services requests."""
|
||||
|
||||
url = URL_API_SERVICES
|
||||
name = "api:services"
|
||||
name = 'api:services'
|
||||
|
||||
async def get(self, request):
|
||||
"""Get registered services."""
|
||||
@@ -297,8 +301,8 @@ class APIServicesView(HomeAssistantView):
|
||||
class APIDomainServicesView(HomeAssistantView):
|
||||
"""View to handle DomainServices requests."""
|
||||
|
||||
url = "/api/services/{domain}/{service}"
|
||||
name = "api:domain-services"
|
||||
url = '/api/services/{domain}/{service}'
|
||||
name = 'api:domain-services'
|
||||
|
||||
async def post(self, request, domain, service):
|
||||
"""Call a service.
|
||||
@@ -310,11 +314,12 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
try:
|
||||
data = json.loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message('Data should be valid JSON',
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Data should be valid JSON.", HTTP_BAD_REQUEST)
|
||||
|
||||
with AsyncTrackStates(hass) as changed_states:
|
||||
await hass.services.async_call(domain, service, data, True)
|
||||
await hass.services.async_call(
|
||||
domain, service, data, True, self.context(request))
|
||||
|
||||
return self.json(changed_states)
|
||||
|
||||
@@ -323,7 +328,7 @@ class APIComponentsView(HomeAssistantView):
|
||||
"""View to handle Components requests."""
|
||||
|
||||
url = URL_API_COMPONENTS
|
||||
name = "api:components"
|
||||
name = 'api:components'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
@@ -332,10 +337,10 @@ class APIComponentsView(HomeAssistantView):
|
||||
|
||||
|
||||
class APITemplateView(HomeAssistantView):
|
||||
"""View to handle requests."""
|
||||
"""View to handle Template requests."""
|
||||
|
||||
url = URL_API_TEMPLATE
|
||||
name = "api:template"
|
||||
name = 'api:template'
|
||||
|
||||
async def post(self, request):
|
||||
"""Render a template."""
|
||||
@@ -344,30 +349,29 @@ class APITemplateView(HomeAssistantView):
|
||||
tpl = template.Template(data['template'], request.app['hass'])
|
||||
return tpl.async_render(data.get('variables'))
|
||||
except (ValueError, TemplateError) as ex:
|
||||
return self.json_message('Error rendering template: {}'.format(ex),
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Error rendering template: {}".format(ex), HTTP_BAD_REQUEST)
|
||||
|
||||
|
||||
class APIErrorLog(HomeAssistantView):
|
||||
"""View to fetch the error log."""
|
||||
"""View to fetch the API error log."""
|
||||
|
||||
url = URL_API_ERROR_LOG
|
||||
name = "api:error_log"
|
||||
name = 'api:error_log'
|
||||
|
||||
async def get(self, request):
|
||||
"""Retrieve API error log."""
|
||||
return web.FileResponse(
|
||||
request.app['hass'].data[DATA_LOGGING])
|
||||
return web.FileResponse(request.app['hass'].data[DATA_LOGGING])
|
||||
|
||||
|
||||
async def async_services_json(hass):
|
||||
"""Generate services data to JSONify."""
|
||||
descriptions = await async_get_all_descriptions(hass)
|
||||
return [{"domain": key, "services": value}
|
||||
return [{'domain': key, 'services': value}
|
||||
for key, value in descriptions.items()]
|
||||
|
||||
|
||||
def async_events_json(hass):
|
||||
"""Generate event data to JSONify."""
|
||||
return [{"event": key, "listener_count": value}
|
||||
return [{'event': key, 'listener_count': value}
|
||||
for key, value in hass.bus.async_listeners().items()]
|
||||
|
||||
@@ -6,7 +6,6 @@ https://home-assistant.io/components/apple_tv/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from typing import Sequence, TypeVar, Union
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -17,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.9']
|
||||
REQUIREMENTS = ['pyatv==0.3.10']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,7 +44,7 @@ NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication'
|
||||
NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification'
|
||||
NOTIFICATION_SCAN_TITLE = 'Apple TV Scan'
|
||||
|
||||
T = TypeVar('T')
|
||||
T = TypeVar('T') # pylint: disable=invalid-name
|
||||
|
||||
|
||||
# This version of ensure_list interprets an empty dict as no value
|
||||
@@ -218,10 +217,10 @@ def _setup_atv(hass, atv_config):
|
||||
ATTR_POWER: power
|
||||
}
|
||||
|
||||
hass.async_add_job(discovery.async_load_platform(
|
||||
hass.async_create_task(discovery.async_load_platform(
|
||||
hass, 'media_player', DOMAIN, atv_config))
|
||||
|
||||
hass.async_add_job(discovery.async_load_platform(
|
||||
hass.async_create_task(discovery.async_load_platform(
|
||||
hass, 'remote', DOMAIN, atv_config))
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def setup(hass, config):
|
||||
return True
|
||||
|
||||
|
||||
class ArduinoBoard(object):
|
||||
class ArduinoBoard:
|
||||
"""Representation of an Arduino board."""
|
||||
|
||||
def __init__(self, port):
|
||||
|
||||
@@ -5,14 +5,18 @@ For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/arlo/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
from requests.exceptions import HTTPError, ConnectTimeout
|
||||
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.const import (
|
||||
CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL)
|
||||
from homeassistant.helpers.event import track_time_interval
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
REQUIREMENTS = ['pyarlo==0.1.2']
|
||||
REQUIREMENTS = ['pyarlo==0.2.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,10 +29,16 @@ DOMAIN = 'arlo'
|
||||
NOTIFICATION_ID = 'arlo_notification'
|
||||
NOTIFICATION_TITLE = 'Arlo Component Setup'
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
SIGNAL_UPDATE_ARLO = "arlo_update"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
|
||||
cv.time_period,
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
@@ -38,6 +48,7 @@ def setup(hass, config):
|
||||
conf = config[DOMAIN]
|
||||
username = conf.get(CONF_USERNAME)
|
||||
password = conf.get(CONF_PASSWORD)
|
||||
scan_interval = conf.get(CONF_SCAN_INTERVAL)
|
||||
|
||||
try:
|
||||
from pyarlo import PyArlo
|
||||
@@ -45,7 +56,19 @@ def setup(hass, config):
|
||||
arlo = PyArlo(username, password, preload=False)
|
||||
if not arlo.is_connected:
|
||||
return False
|
||||
|
||||
# assign refresh period to base station thread
|
||||
arlo_base_station = next((
|
||||
station for station in arlo.base_stations), None)
|
||||
|
||||
if arlo_base_station is not None:
|
||||
arlo_base_station.refresh_rate = scan_interval.total_seconds()
|
||||
elif not arlo.cameras:
|
||||
_LOGGER.error("No Arlo camera or base station available.")
|
||||
return False
|
||||
|
||||
hass.data[DATA_ARLO] = arlo
|
||||
|
||||
except (ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex))
|
||||
hass.components.persistent_notification.create(
|
||||
@@ -55,4 +78,17 @@ def setup(hass, config):
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID)
|
||||
return False
|
||||
|
||||
def hub_refresh(event_time):
|
||||
"""Call ArloHub to refresh information."""
|
||||
_LOGGER.info("Updating Arlo Hub component")
|
||||
hass.data[DATA_ARLO].update(update_cameras=True,
|
||||
update_base_station=True)
|
||||
dispatcher_send(hass, SIGNAL_UPDATE_ARLO)
|
||||
|
||||
# register service
|
||||
hass.services.register(DOMAIN, 'update', hub_refresh)
|
||||
|
||||
# register scan interval for ArloHub
|
||||
track_time_interval(hass, hub_refresh, scan_interval)
|
||||
return True
|
||||
|
||||
@@ -13,16 +13,19 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect, async_dispatcher_send)
|
||||
async_dispatcher_send, dispatcher_connect)
|
||||
|
||||
REQUIREMENTS = ['asterisk_mbox==0.4.0']
|
||||
REQUIREMENTS = ['asterisk_mbox==0.5.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = 'asterisk_mbox'
|
||||
|
||||
SIGNAL_DISCOVER_PLATFORM = "asterisk_mbox.discover_platform"
|
||||
SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request'
|
||||
SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated'
|
||||
SIGNAL_CDR_UPDATE = 'asterisk_mbox.message_updated'
|
||||
SIGNAL_CDR_REQUEST = 'asterisk_mbox.message_request'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
@@ -41,41 +44,79 @@ def setup(hass, config):
|
||||
port = conf.get(CONF_PORT)
|
||||
password = conf.get(CONF_PASSWORD)
|
||||
|
||||
hass.data[DOMAIN] = AsteriskData(hass, host, port, password)
|
||||
|
||||
discovery.load_platform(hass, 'mailbox', DOMAIN, {}, config)
|
||||
hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AsteriskData(object):
|
||||
class AsteriskData:
|
||||
"""Store Asterisk mailbox data."""
|
||||
|
||||
def __init__(self, hass, host, port, password):
|
||||
def __init__(self, hass, host, port, password, config):
|
||||
"""Init the Asterisk data object."""
|
||||
from asterisk_mbox import Client as asteriskClient
|
||||
|
||||
self.hass = hass
|
||||
self.client = asteriskClient(host, port, password, self.handle_data)
|
||||
self.messages = []
|
||||
self.config = config
|
||||
self.messages = None
|
||||
self.cdr = None
|
||||
|
||||
async_dispatcher_connect(
|
||||
dispatcher_connect(
|
||||
self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages)
|
||||
dispatcher_connect(
|
||||
self.hass, SIGNAL_CDR_REQUEST, self._request_cdr)
|
||||
dispatcher_connect(
|
||||
self.hass, SIGNAL_DISCOVER_PLATFORM, self._discover_platform)
|
||||
# Only connect after signal connection to ensure we don't miss any
|
||||
self.client = asteriskClient(host, port, password, self.handle_data)
|
||||
|
||||
@callback
|
||||
def _discover_platform(self, component):
|
||||
_LOGGER.debug("Adding mailbox %s", component)
|
||||
self.hass.async_create_task(discovery.async_load_platform(
|
||||
self.hass, "mailbox", component, {}, self.config))
|
||||
|
||||
@callback
|
||||
def handle_data(self, command, msg):
|
||||
"""Handle changes to the mailbox."""
|
||||
from asterisk_mbox.commands import CMD_MESSAGE_LIST
|
||||
from asterisk_mbox.commands import (CMD_MESSAGE_LIST,
|
||||
CMD_MESSAGE_CDR_AVAILABLE,
|
||||
CMD_MESSAGE_CDR)
|
||||
|
||||
if command == CMD_MESSAGE_LIST:
|
||||
_LOGGER.debug("AsteriskVM sent updated message list")
|
||||
_LOGGER.debug("AsteriskVM sent updated message list: Len %d",
|
||||
len(msg))
|
||||
old_messages = self.messages
|
||||
self.messages = sorted(
|
||||
msg, key=lambda item: item['info']['origtime'], reverse=True)
|
||||
async_dispatcher_send(
|
||||
self.hass, SIGNAL_MESSAGE_UPDATE, self.messages)
|
||||
if not isinstance(old_messages, list):
|
||||
async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM,
|
||||
DOMAIN)
|
||||
async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE,
|
||||
self.messages)
|
||||
elif command == CMD_MESSAGE_CDR:
|
||||
_LOGGER.debug("AsteriskVM sent updated CDR list: Len %d",
|
||||
len(msg.get('entries', [])))
|
||||
self.cdr = msg['entries']
|
||||
async_dispatcher_send(self.hass, SIGNAL_CDR_UPDATE, self.cdr)
|
||||
elif command == CMD_MESSAGE_CDR_AVAILABLE:
|
||||
if not isinstance(self.cdr, list):
|
||||
_LOGGER.debug("AsteriskVM adding CDR platform")
|
||||
self.cdr = []
|
||||
async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM,
|
||||
"asterisk_cdr")
|
||||
async_dispatcher_send(self.hass, SIGNAL_CDR_REQUEST)
|
||||
else:
|
||||
_LOGGER.debug("AsteriskVM sent unknown message '%d' len: %d",
|
||||
command, len(msg))
|
||||
|
||||
@callback
|
||||
def _request_messages(self):
|
||||
"""Handle changes to the mailbox."""
|
||||
_LOGGER.debug("Requesting message list")
|
||||
self.client.messages()
|
||||
|
||||
@callback
|
||||
def _request_cdr(self):
|
||||
"""Handle changes to the CDR."""
|
||||
_LOGGER.debug("Requesting CDR list")
|
||||
self.client.get_cdr()
|
||||
|
||||
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CONFIGURING = {}
|
||||
|
||||
REQUIREMENTS = ['py-august==0.4.0']
|
||||
REQUIREMENTS = ['py-august==0.6.0']
|
||||
|
||||
DEFAULT_TIMEOUT = 10
|
||||
ACTIVITY_FETCH_LIMIT = 10
|
||||
@@ -123,9 +123,9 @@ def setup_august(hass, config, api, authenticator):
|
||||
discovery.load_platform(hass, component, DOMAIN, {}, config)
|
||||
|
||||
return True
|
||||
elif state == AuthenticationState.BAD_PASSWORD:
|
||||
if state == AuthenticationState.BAD_PASSWORD:
|
||||
return False
|
||||
elif state == AuthenticationState.REQUIRES_VALIDATION:
|
||||
if state == AuthenticationState.REQUIRES_VALIDATION:
|
||||
request_configuration(hass, config, api, authenticator)
|
||||
return True
|
||||
|
||||
|
||||
7
homeassistant/components/auth/.translations/ar.json
Normal file
7
homeassistant/components/auth/.translations/ar.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/ca.json
Normal file
35
homeassistant/components/auth/.translations/ca.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "No hi ha serveis de notificaci\u00f3 disponibles."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Codi inv\u00e0lid, si us plau torni a provar-ho."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Seleccioneu un dels serveis de notificaci\u00f3:",
|
||||
"title": "Configureu una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions"
|
||||
},
|
||||
"setup": {
|
||||
"description": "**notify.{notify_service}** ha enviat una contrasenya d'un sol \u00fas. Introdu\u00efu-la a continuaci\u00f3:",
|
||||
"title": "Verifiqueu la configuraci\u00f3"
|
||||
}
|
||||
},
|
||||
"title": "Contrasenya d'un sol \u00fas del servei de notificacions"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Codi inv\u00e0lid, si us plau torni a provar-ho. Si obteniu aquest error repetidament, assegureu-vos que la data i hora de Home Assistant sigui correcta i precisa."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escanegeu el codi QR amb la vostre aplicaci\u00f3 de verificaci\u00f3. Si no en teniu cap, us recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdu\u00efu el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si teniu problemes per escanejar el codi QR, feu una configuraci\u00f3 manual amb el codi **`{code}`**.",
|
||||
"title": "Configureu la verificaci\u00f3 en dos passos utilitzant TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
homeassistant/components/auth/.translations/de.json
Normal file
34
homeassistant/components/auth/.translations/de.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "Keine Benachrichtigungsdienste verf\u00fcgbar."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Bitte w\u00e4hle einen der Benachrichtigungsdienste:",
|
||||
"title": "Einmal Passwort f\u00fcr Notify einrichten"
|
||||
},
|
||||
"setup": {
|
||||
"description": "Ein Einmal-Passwort wurde per ** notify gesendet. {notify_service} **. Bitte gebe es unten ein:",
|
||||
"title": "\u00dcberpr\u00fcfe das Setup"
|
||||
}
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut. Wenn du diesen Fehler regelm\u00e4\u00dfig erhalten, stelle sicher, dass die Uhr deines Home Assistant-Systems korrekt ist."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Um die Zwei-Faktor-Authentifizierung mit zeitbasierten Einmalpassw\u00f6rtern zu aktivieren, scanne den QR-Code mit Ihrer Authentifizierungs-App. Wenn du keine hast, empfehlen wir entweder [Google Authenticator] (https://support.google.com/accounts/answer/1066447) oder [Authy] (https://authy.com/). \n\n {qr_code} \n \nNachdem du den Code gescannt hast, gebe den sechsstelligen Code aus der App ein, um das Setup zu \u00fcberpr\u00fcfen. Wenn es Probleme beim Scannen des QR-Codes gibt, f\u00fchre ein manuelles Setup mit dem Code ** ` {code} ` ** durch.",
|
||||
"title": "Richte die Zwei-Faktor-Authentifizierung mit TOTP ein"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/en.json
Normal file
35
homeassistant/components/auth/.translations/en.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "No notification services available."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Invalid code, please try again."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Please select one of the notification services:",
|
||||
"title": "Set up one-time password delivered by notify component"
|
||||
},
|
||||
"setup": {
|
||||
"description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:",
|
||||
"title": "Verify setup"
|
||||
}
|
||||
},
|
||||
"title": "Notify One-Time Password"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.",
|
||||
"title": "Set up two-factor authentication using TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
homeassistant/components/auth/.translations/es-419.json
Normal file
12
homeassistant/components/auth/.translations/es-419.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Configurar la autenticaci\u00f3n de dos factores mediante TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
homeassistant/components/auth/.translations/fr.json
Normal file
23
homeassistant/components/auth/.translations/fr.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"step": {
|
||||
"setup": {
|
||||
"description": "Un mot de passe unique a \u00e9t\u00e9 envoy\u00e9 par **notify.{notify_service}**. Veuillez le saisir ci-dessous :"
|
||||
}
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Code invalide. Veuillez essayez \u00e0 nouveau. Si cette erreur persiste, assurez-vous que l'horloge de votre syst\u00e8me Home Assistant est correcte."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Pour activer l'authentification \u00e0 deux facteurs \u00e0 l'aide de mots de passe \u00e0 utilisation unique bas\u00e9s sur l'heure, num\u00e9risez le code QR avec votre application d'authentification. Si vous n'en avez pas, nous vous recommandons d'utiliser [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ou [Authy] (https://authy.com/). \n\n {qr_code} \n \n Apr\u00e8s avoir num\u00e9ris\u00e9 le code, entrez le code \u00e0 six chiffres de votre application pour v\u00e9rifier la configuration. Si vous rencontrez des probl\u00e8mes lors de l\u2019analyse du code QR, effectuez une configuration manuelle avec le code ** ` {code} ` **.",
|
||||
"title": "Configurer une authentification \u00e0 deux facteurs \u00e0 l'aide de TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP (Mot de passe \u00e0 utilisation unique bas\u00e9 sur le temps)"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/he.json
Normal file
35
homeassistant/components/auth/.translations/he.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 notify \u05d6\u05de\u05d9\u05e0\u05d9\u05dd."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d0\u05d7\u05d3 \u05de\u05e9\u05e8\u05d5\u05ea\u05d9 notify",
|
||||
"title": "\u05d4\u05d2\u05d3\u05e8 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05d4\u05e0\u05e9\u05dc\u05d7\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e8\u05db\u05d9\u05d1 notify"
|
||||
},
|
||||
"setup": {
|
||||
"description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 **{notify_service}**. \u05d4\u05d6\u05df \u05d0\u05d5\u05ea\u05d4 \u05dc\u05de\u05d8\u05d4:",
|
||||
"title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4"
|
||||
}
|
||||
},
|
||||
"title": "\u05dc\u05d4\u05d5\u05d3\u05d9\u05e2 \u200b\u200b\u05e2\u05dc \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05de\u05e7\u05d1\u05dc \u05d0\u05ea \u05d4\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d4\u05d6\u05d5 \u05d1\u05d0\u05d5\u05e4\u05df \u05e2\u05e7\u05d1\u05d9, \u05d5\u05d3\u05d0 \u05e9\u05d4\u05e9\u05e2\u05d5\u05df \u05e9\u05dc \u05de\u05e2\u05e8\u05db\u05ea \u05d4 - Home Assistant \u05e9\u05dc\u05da \u05de\u05d3\u05d5\u05d9\u05e7."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u05db\u05d3\u05d9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05d5\u05ea \u05de\u05d1\u05d5\u05e1\u05e1\u05d5\u05ea \u05d6\u05de\u05df, \u05e1\u05e8\u05d5\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 QR \u05e2\u05dd \u05d9\u05d9\u05e9\u05d5\u05dd \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05da. \u05d0\u05dd \u05d0\u05d9\u05df \u05dc\u05da \u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d6\u05d4, \u05d0\u05e0\u05d5 \u05de\u05de\u05dc\u05d9\u05e6\u05d9\u05dd \u05e2\u05dc [Google Authenticator] (https://support.google.com/accounts/answer/1066447) \u05d0\u05d5 [Authy] (https://authy.com/). \n\n {qr_code} \n \n \u05dc\u05d0\u05d7\u05e8 \u05e1\u05e8\u05d9\u05e7\u05ea \u05d4\u05e7\u05d5\u05d3, \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05de\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d0\u05de\u05ea \u05d0\u05ea \u05d4\u05d4\u05d2\u05d3\u05e8\u05d4. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05e0\u05ea\u05e7\u05dc \u05d1\u05d1\u05e2\u05d9\u05d5\u05ea \u05d1\u05e1\u05e8\u05d9\u05e7\u05ea \u05e7\u05d5\u05d3 QR, \u05d1\u05e6\u05e2 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d9\u05d3\u05e0\u05d9\u05ea \u05e2\u05dd \u05e7\u05d5\u05d3 **`{code}`**.",
|
||||
"title": "\u05d4\u05d2\u05d3\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
homeassistant/components/auth/.translations/hu.json
Normal file
16
homeassistant/components/auth/.translations/hu.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj meg r\u00f3la, hogy a Home Assistant rendszered \u00f3r\u00e1ja pontosan j\u00e1r."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Ahhoz, hogy haszn\u00e1lhasd a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkenneld be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3ddal. Ha m\u00e9g nincsen, akkor a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t aj\u00e1nljuk.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n add meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6z\u00f6l a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edts egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.",
|
||||
"title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s be\u00e1ll\u00edt\u00e1sa TOTP haszn\u00e1lat\u00e1val"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
homeassistant/components/auth/.translations/id.json
Normal file
16
homeassistant/components/auth/.translations/id.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Kode salah, coba lagi. Jika Anda mendapatkan kesalahan ini secara konsisten, pastikan jam pada sistem Home Assistant anda akurat."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Untuk mengaktifkan otentikasi dua faktor menggunakan password satu kali berbasis waktu, pindai kode QR dengan aplikasi otentikasi Anda. Jika Anda tidak memilikinya, kami menyarankan [Google Authenticator] (https://support.google.com/accounts/answer/1066447) atau [Authy] (https://authy.com/). \n\n {qr_code} \n \n Setelah memindai kode, masukkan kode enam digit dari aplikasi Anda untuk memverifikasi pengaturan. Jika Anda mengalami masalah saat memindai kode QR, lakukan pengaturan manual dengan kode ** ` {code} ` **.",
|
||||
"title": "Siapkan otentikasi dua faktor menggunakan TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
homeassistant/components/auth/.translations/it.json
Normal file
13
homeassistant/components/auth/.translations/it.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"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} ` **.",
|
||||
"title": "Imposta l'autenticazione a due fattori usando TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/ko.json
Normal file
35
homeassistant/components/auth/.translations/ko.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c \uc54c\ub9bc \uc11c\ube44\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc\uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\uc54c\ub9bc \uc11c\ube44\uc2a4 \uc911 \ud558\ub098\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694:",
|
||||
"title": "\uc54c\ub9bc \uad6c\uc131\uc694\uc18c\uac00 \uc81c\uacf5\ud558\ub294 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc124\uc815"
|
||||
},
|
||||
"setup": {
|
||||
"description": "**notify.{notify_service}** \uc5d0\uc11c \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \ubcf4\ub0c8\uc2b5\ub2c8\ub2e4. \uc544\ub798\uc758 \uacf5\ub780\uc5d0 \uc785\ub825\ud574 \uc8fc\uc138\uc694:",
|
||||
"title": "\uc124\uc815 \ud655\uc778"
|
||||
}
|
||||
},
|
||||
"title": "\uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc54c\ub9bc"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc \uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694. \uc774 \uc624\ub958\uac00 \uc9c0\uc18d\uc801\uc73c\ub85c \ubc1c\uc0dd\ud55c\ub2e4\uba74 Home Assistant \uc758 \uc2dc\uac04\uc124\uc815\uc774 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\ubcf4\uc138\uc694."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\uc2dc\uac04 \uae30\ubc18\uc758 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \uc0ac\uc6a9\ud558\ub294 2\ub2e8\uacc4 \uc778\uc99d\uc744 \ud558\ub824\uba74 \uc778\uc99d\uc6a9 \uc571\uc744 \uc774\uc6a9\ud574\uc11c QR \ucf54\ub4dc\ub97c \uc2a4\uce94\ud574 \uc8fc\uc138\uc694. \uc778\uc99d\uc6a9 \uc571\uc740 [Google OTP](https://support.google.com/accounts/answer/1066447) \ub610\ub294 [Authy](https://authy.com/) \ub97c \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.\n\n{qr_code}\n\n\uc2a4\uce94 \ud6c4\uc5d0 \uc0dd\uc131\ub41c 6\uc790\ub9ac \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc11c \uc124\uc815\uc744 \ud655\uc778\ud558\uc138\uc694. QR \ucf54\ub4dc \uc2a4\uce94\uc5d0 \ubb38\uc81c\uac00 \uc788\ub2e4\uba74, **`{code}`** \ucf54\ub4dc\ub85c \uc9c1\uc811 \uc124\uc815\ud574\ubcf4\uc138\uc694.",
|
||||
"title": "TOTP \ub97c \uc0ac\uc6a9\ud558\uc5ec 2 \ub2e8\uacc4 \uc778\uc99d \uad6c\uc131"
|
||||
}
|
||||
},
|
||||
"title": "TOTP (\uc2dc\uac04 \uae30\ubc18 OTP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/lb.json
Normal file
35
homeassistant/components/auth/.translations/lb.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "Keen Notifikatioun's D\u00e9ngscht disponibel."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Ong\u00ebltege Code, prob\u00e9iert w.e.g. nach emol."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Wielt w.e.g. een Notifikatioun's D\u00e9ngscht aus:",
|
||||
"title": "Eemolegt Passwuert ariichte wat vun engem Notifikatioun's Komponente versch\u00e9ckt g\u00ebtt"
|
||||
},
|
||||
"setup": {
|
||||
"description": "Een eemolegt Passwuert ass vun **notify.{notify_service}** gesch\u00e9ckt ginn. Gitt et w.e.g hei \u00ebnnen dr\u00ebnner an:",
|
||||
"title": "Astellungen iwwerpr\u00e9iwen"
|
||||
}
|
||||
},
|
||||
"title": "Eemolegt Passwuert Notifikatioun"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol. Falls d\u00ebse Feeler Message \u00ebmmer er\u00ebm optr\u00ebtt dann iwwerpr\u00e9ift op d'Z\u00e4it vum Home Assistant System richteg ass."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Fir d'Zwee-Faktor-Authentifikatioun m\u00ebttels engem Z\u00e4it bas\u00e9ierten eemolege Passwuert z'aktiv\u00e9ieren, scannt de QR Code mat enger Authentifikatioun's App.\nFalls dir keng hutt, recommand\u00e9iere mir entweder [Google Authenticator](https://support.google.com/accounts/answer/1066447) oder [Authy](https://authy.com/).\n\n{qr_code}\n\nNodeems de Code gescannt ass, gitt de sechs stellege Code vun der App a fir d'Konfiguratioun z'iwwerpr\u00e9iwen. Am Fall vu Problemer fir de QR Code ze scannen, gitt de folgende Code **`{code}`** a fir ee manuelle Setup.",
|
||||
"title": "Zwee Faktor Authentifikatioun mat TOTP konfigur\u00e9ieren"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
homeassistant/components/auth/.translations/nl.json
Normal file
16
homeassistant/components/auth/.translations/nl.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Ongeldige code, probeer het opnieuw. Als u deze fout blijft krijgen, controleer dan of de klok van uw Home Assistant systeem correct is ingesteld."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Voor het activeren van twee-factor-authenticatie via tijdgebonden eenmalige wachtwoorden: scan de QR code met uw authenticatie-app. Als u nog geen app heeft, adviseren we [Google Authenticator (https://support.google.com/accounts/answer/1066447) of [Authy](https://authy.com/).\n\n{qr_code}\n\nNa het scannen van de code voert u de zescijferige code uit uw app in om de instelling te controleren. Als u problemen heeft met het scannen van de QR-code, voert u een handmatige configuratie uit met code **`{code}`**.",
|
||||
"title": "Configureer twee-factor-authenticatie via TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
homeassistant/components/auth/.translations/nn.json
Normal file
16
homeassistant/components/auth/.translations/nn.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Ugyldig kode, pr\u00f8v igjen. Dersom du heile tida f\u00e5r denne feilen, m\u00e5 du s\u00f8rge for at klokka p\u00e5 Home Assistant-systemet ditt er n\u00f8yaktig."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "For \u00e5 aktivere tofaktorautentisering ved hjelp av tidsbaserte eingangspassord, skann QR-koden med autentiseringsappen din. Dersom du ikkje har ein, vil vi r\u00e5de deg til \u00e5 bruke anten [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n {qr_code} \n \nN\u00e5r du har skanna koda, skriv du inn den sekssifra koda fr\u00e5 appen din for \u00e5 stadfeste oppsettet. Dersom du har problemer med \u00e5 skanne QR-koda, gjer du eit manuelt oppsett med kode ** ` {code} ` **.",
|
||||
"title": "Konfigurer to-faktor-autentisering ved hjelp av TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/no.json
Normal file
35
homeassistant/components/auth/.translations/no.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "Ingen varslingstjenester er tilgjengelig."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Ugyldig kode, vennligst pr\u00f8v igjen."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Vennligst velg en av varslingstjenestene:",
|
||||
"title": "Sett opp engangspassord levert av varsel komponent"
|
||||
},
|
||||
"setup": {
|
||||
"description": "Et engangspassord har blitt sendt via **notify.{notify_service}**. Vennligst skriv det inn nedenfor:",
|
||||
"title": "Bekreft oppsettet"
|
||||
}
|
||||
},
|
||||
"title": "Varsle engangspassord"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Ugyldig kode, pr\u00f8v igjen. Hvis du f\u00e5r denne feilen konsekvent, m\u00e5 du s\u00f8rge for at klokken p\u00e5 Home Assistant systemet er riktig."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "For \u00e5 aktivere tofaktorautentisering ved hjelp av tidsbaserte engangspassord, skann QR-koden med autentiseringsappen din. Hvis du ikke har en, kan vi anbefale enten [Google Authenticator](https://support.google.com/accounts/answer/1066447) eller [Authy](https://authy.com/). \n\n {qr_code} \n \nEtter at du har skannet koden, skriver du inn den seks-sifrede koden fra appen din for \u00e5 kontrollere oppsettet. Dersom du har problemer med \u00e5 skanne QR-koden kan du taste inn f\u00f8lgende kode manuelt: **`{code}`**.",
|
||||
"title": "Konfigurer tofaktorautentisering ved hjelp av TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
homeassistant/components/auth/.translations/pl.json
Normal file
34
homeassistant/components/auth/.translations/pl.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "Brak dost\u0119pnych us\u0142ug powiadamiania."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Prosz\u0119 wybra\u0107 jedn\u0105 z us\u0142ugi powiadamiania:",
|
||||
"title": "Skonfiguruj has\u0142o jednorazowe dostarczone przez komponent powiadomie\u0144"
|
||||
},
|
||||
"setup": {
|
||||
"description": "Has\u0142o jednorazowe zosta\u0142o wys\u0142ane przez ** powiadom. {notify_service} **. Wpisz je poni\u017cej:",
|
||||
"title": "Sprawd\u017a konfiguracj\u0119"
|
||||
}
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie. Je\u015bli b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, upewnij si\u0119, \u017ce czas zegara systemu Home Assistant jest prawid\u0142owy."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Aby aktywowa\u0107 uwierzytelnianie dwusk\u0142adnikowe przy u\u017cyciu jednorazowych hase\u0142 opartych na czasie, zeskanuj kod QR za pomoc\u0105 aplikacji uwierzytelniaj\u0105cej. Je\u015bli jej nie masz, polecamy [Google Authenticator](https://support.google.com/accounts/answer/1066447) lub [Authy](https://authy.com/).\n\n{qr_code} \n \nPo zeskanowaniu kodu wprowad\u017a sze\u015bciocyfrowy kod z aplikacji, aby zweryfikowa\u0107 konfiguracj\u0119. Je\u015bli masz problemy z zeskanowaniem kodu QR, wykonaj r\u0119czn\u0105 konfiguracj\u0119 z kodem **`{code}`**.",
|
||||
"title": "Skonfiguruj uwierzytelnianie dwusk\u0142adnikowe za pomoc\u0105 hase\u0142 jednorazowych opartych na czasie"
|
||||
}
|
||||
},
|
||||
"title": "Has\u0142a jednorazowe oparte na czasie"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
homeassistant/components/auth/.translations/pt-BR.json
Normal file
15
homeassistant/components/auth/.translations/pt-BR.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente. Se voc\u00ea obtiver este erro de forma consistente, certifique-se de que o rel\u00f3gio do sistema Home Assistant esteja correto."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Configure a autentica\u00e7\u00e3o de dois fatores usando o TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
homeassistant/components/auth/.translations/pt.json
Normal file
16
homeassistant/components/auth/.translations/pt.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "C\u00f3digo inv\u00e1lido, por favor, tente novamente. Se receber este erro constantemente, por favor, certifique-se de que o rel\u00f3gio do sistema que hospeda o Home Assistent \u00e9 preciso."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Para ativar a autentica\u00e7\u00e3o com dois fatores utilizando passwords unicas temporais (OTP), ler o c\u00f3digo QR com a sua aplica\u00e7\u00e3o de autentica\u00e7\u00e3o. Se voc\u00ea n\u00e3o tiver uma, recomendamos [Google Authenticator](https://support.google.com/accounts/answer/1066447) ou [Authy](https://authy.com/).\n\n{qr_code}\n\nDepois de ler o c\u00f3digo, introduza o c\u00f3digo de seis d\u00edgitos fornecido pela sua aplica\u00e7\u00e3o para verificar a configura\u00e7\u00e3o. Se tiver problemas a ler o c\u00f3digo QR, fa\u00e7a uma configura\u00e7\u00e3o manual com o c\u00f3digo **`{c\u00f3digo}`**.",
|
||||
"title": "Configurar autentica\u00e7\u00e3o com dois fatores usando TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/ru.json
Normal file
35
homeassistant/components/auth/.translations/ru.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "\u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043a\u043e\u0434, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u0443 \u0438\u0437 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439:",
|
||||
"title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043e\u0441\u0442\u0430\u0432\u043a\u0438 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439 \u0447\u0435\u0440\u0435\u0437 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439"
|
||||
},
|
||||
"setup": {
|
||||
"description": "\u041e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d \u0447\u0435\u0440\u0435\u0437 **notify.{notify_service}**. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0435\u0433\u043e \u043d\u0438\u0436\u0435:",
|
||||
"title": "\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443"
|
||||
}
|
||||
},
|
||||
"title": "\u0414\u043e\u0441\u0442\u0430\u0432\u043a\u0430 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430. \u0415\u0441\u043b\u0438 \u0432\u044b \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442\u0435 \u044d\u0442\u0443 \u043e\u0448\u0438\u0431\u043a\u0443, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0447\u0430\u0441\u044b \u0432 \u0432\u0430\u0448\u0435\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Home Assistant \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u044e\u0442 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u0427\u0442\u043e\u0431\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443\u044e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439, \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u043d\u044b\u0445 \u043d\u0430 \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043e\u0442\u0441\u043a\u0430\u043d\u0438\u0440\u0443\u0439\u0442\u0435 QR-\u043a\u043e\u0434 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0434\u043b\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0433\u043e \u043d\u0435\u0442, \u043c\u044b \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043b\u0438\u0431\u043e [Google Authenticator](https://support.google.com/accounts/answer/1066447), \u043b\u0438\u0431\u043e [Authy](https://authy.com/). \n\n {qr_code} \n \n\u041f\u043e\u0441\u043b\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f QR-\u043a\u043e\u0434\u0430 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0448\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u043d\u044b\u0439 \u043a\u043e\u0434 \u0438\u0437 \u0432\u0430\u0448\u0435\u0433\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441\u043e \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c QR-\u043a\u043e\u0434\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043a\u043e\u0434\u0430 **`{code}`**.",
|
||||
"title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/sl.json
Normal file
35
homeassistant/components/auth/.translations/sl.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "Ni na voljo storitev obve\u0161\u010danja."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Neveljavna koda, poskusite znova."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Prosimo, 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:",
|
||||
"title": "Preverite nastavitev"
|
||||
}
|
||||
},
|
||||
"title": "Obvesti Enkratno Geslo"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Neveljavna koda, prosimo, poskusite znova. \u010ce dobite to sporo\u010dilo ve\u010dkrat, prosimo poskrbite, da bo ura va\u0161ega Home Assistenta to\u010dna."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u010ce \u017eelite aktivirati preverjanje pristnosti dveh faktorjev z enkratnimi gesli, ki temeljijo na \u010dasu, skenirajte kodo QR s svojo aplikacijo za preverjanje pristnosti. \u010ce je nimate, priporo\u010damo bodisi [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ali [Authy] (https://authy.com/). \n\n {qr_code} \n \n Po skeniranju kode vnesite \u0161estmestno kodo iz aplikacije, da preverite nastavitev. \u010ce imate te\u017eave pri skeniranju kode QR, naredite ro\u010dno nastavitev s kodo ** ` {code} ` **.",
|
||||
"title": "Nastavite dvofaktorsko avtentifikacijo s pomo\u010djo TOTP-ja"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
30
homeassistant/components/auth/.translations/sv.json
Normal file
30
homeassistant/components/auth/.translations/sv.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "Inga tillg\u00e4ngliga meddelande tj\u00e4nster."
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "Ogiltig kod, var god f\u00f6rs\u00f6k igen."
|
||||
},
|
||||
"step": {
|
||||
"setup": {
|
||||
"description": "Ett eng\u00e5ngsl\u00f6senord har skickats av **notify.{notify_service}**. V\u00e4nligen ange det nedan:",
|
||||
"title": "Verifiera installationen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "Ogiltig kod, f\u00f6rs\u00f6k igen. Om du flera g\u00e5nger i rad f\u00e5r detta fel, se till att klockan i din Home Assistant \u00e4r korrekt inst\u00e4lld."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "F\u00f6r att aktivera tv\u00e5faktorsautentisering som anv\u00e4nder tidsbaserade eng\u00e5ngsl\u00f6senord, skanna QR-koden med din autentiseringsapp. Om du inte har en, rekommenderar vi antingen [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n{qr_code} \n\nN\u00e4r du har skannat koden anger du den sexsiffriga koden fr\u00e5n din app f\u00f6r att verifiera inst\u00e4llningen. Om du har problem med att skanna QR-koden, g\u00f6r en manuell inst\u00e4llning med kod ** ` {code} ` **.",
|
||||
"title": "St\u00e4ll in tv\u00e5faktorsautentisering med TOTP"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/zh-Hans.json
Normal file
35
homeassistant/components/auth/.translations/zh-Hans.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "\u6ca1\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52a1\u3002"
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "\u4ee3\u7801\u65e0\u6548\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u8bf7\u4ece\u4e0b\u9762\u9009\u62e9\u4e00\u4e2a\u901a\u77e5\u670d\u52a1\uff1a",
|
||||
"title": "\u8bbe\u7f6e\u7531\u901a\u77e5\u7ec4\u4ef6\u4f20\u9012\u7684\u4e00\u6b21\u6027\u5bc6\u7801"
|
||||
},
|
||||
"setup": {
|
||||
"description": "\u4e00\u6b21\u6027\u5bc6\u7801\u5df2\u7531 **notify.{notify_service}** \u53d1\u9001\u3002\u8bf7\u5728\u4e0b\u9762\u8f93\u5165\uff1a",
|
||||
"title": "\u9a8c\u8bc1\u8bbe\u7f6e"
|
||||
}
|
||||
},
|
||||
"title": "\u4e00\u6b21\u6027\u5bc6\u7801\u901a\u77e5"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "\u53e3\u4ee4\u65e0\u6548\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165\u3002\u5982\u679c\u9519\u8bef\u53cd\u590d\u51fa\u73b0\uff0c\u8bf7\u786e\u4fdd Home Assistant \u7cfb\u7edf\u7684\u65f6\u95f4\u51c6\u786e\u65e0\u8bef\u3002"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u8981\u6fc0\u6d3b\u57fa\u4e8e\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u7684\u53cc\u91cd\u8ba4\u8bc1\uff0c\u8bf7\u7528\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\u626b\u63cf\u4ee5\u4e0b\u4e8c\u7ef4\u7801\u3002\u5982\u679c\u60a8\u8fd8\u6ca1\u6709\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\uff0c\u63a8\u8350\u4f7f\u7528 [Google \u8eab\u4efd\u9a8c\u8bc1\u5668](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u626b\u63cf\u4e8c\u7ef4\u7801\u4ee5\u540e\uff0c\u8f93\u5165\u5e94\u7528\u4e0a\u7684\u516d\u4f4d\u6570\u5b57\u53e3\u4ee4\u6765\u9a8c\u8bc1\u914d\u7f6e\u3002\u5982\u679c\u5728\u626b\u63cf\u4e8c\u7ef4\u7801\u65f6\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u4f7f\u7528\u4ee3\u7801 **`{code}`** \u624b\u52a8\u914d\u7f6e\u3002",
|
||||
"title": "\u7528\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u8bbe\u7f6e\u53cc\u91cd\u8ba4\u8bc1"
|
||||
}
|
||||
},
|
||||
"title": "\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/auth/.translations/zh-Hant.json
Normal file
35
homeassistant/components/auth/.translations/zh-Hant.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"mfa_setup": {
|
||||
"notify": {
|
||||
"abort": {
|
||||
"no_available_service": "\u6c92\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52d9\u3002"
|
||||
},
|
||||
"error": {
|
||||
"invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u8acb\u9078\u64c7\u4e00\u9805\u901a\u77e5\u670d\u52d9\uff1a",
|
||||
"title": "\u8a2d\u5b9a\u4e00\u6b21\u6027\u5bc6\u78bc\u50b3\u9001\u7d44\u4ef6"
|
||||
},
|
||||
"setup": {
|
||||
"description": "\u4e00\u6b21\u6027\u5bc6\u78bc\u5df2\u900f\u904e **notify.{notify_service}** \u50b3\u9001\u3002\u8acb\u65bc\u4e0b\u65b9\u8f38\u5165\uff1a",
|
||||
"title": "\u9a57\u8b49\u8a2d\u5b9a"
|
||||
}
|
||||
},
|
||||
"title": "\u901a\u77e5\u4e00\u6b21\u6027\u5bc6\u78bc"
|
||||
},
|
||||
"totp": {
|
||||
"error": {
|
||||
"invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002\u5047\u5982\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u5148\u78ba\u5b9a\u60a8\u7684 Home Assistant \u7cfb\u7d71\u4e0a\u7684\u6642\u9593\u8a2d\u5b9a\u6b63\u78ba\u5f8c\uff0c\u518d\u8a66\u4e00\u6b21\u3002"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "\u6b32\u555f\u7528\u4e00\u6b21\u6027\u4e14\u5177\u6642\u6548\u6027\u7684\u5bc6\u78bc\u4e4b\u5169\u6b65\u9a5f\u9a57\u8b49\u529f\u80fd\uff0c\u8acb\u4f7f\u7528\u60a8\u7684\u9a57\u8b49 App \u6383\u7784\u4e0b\u65b9\u7684 QR code \u3002\u5018\u82e5\u60a8\u5c1a\u672a\u5b89\u88dd\u4efb\u4f55 App\uff0c\u63a8\u85a6\u60a8\u4f7f\u7528 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u65bc\u6383\u63cf\u4e4b\u5f8c\uff0c\u8f38\u5165 App \u4e2d\u7684\u516d\u4f4d\u6578\u5b57\u9032\u884c\u8a2d\u5b9a\u9a57\u8b49\u3002\u5047\u5982\u6383\u63cf\u51fa\u73fe\u554f\u984c\uff0c\u8acb\u624b\u52d5\u8f38\u5165\u4ee5\u4e0b\u9a57\u8b49\u78bc **`{code}`**\u3002",
|
||||
"title": "\u4f7f\u7528 TOTP \u8a2d\u5b9a\u5169\u6b65\u9a5f\u9a57\u8b49"
|
||||
}
|
||||
},
|
||||
"title": "TOTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,5 @@
|
||||
"""Component to allow users to login and get tokens.
|
||||
|
||||
All requests will require passing in a valid client ID and secret via HTTP
|
||||
Basic Auth.
|
||||
|
||||
# GET /auth/providers
|
||||
|
||||
Return a list of auth providers. Example:
|
||||
|
||||
[
|
||||
{
|
||||
"name": "Local",
|
||||
"id": null,
|
||||
"type": "local_provider",
|
||||
}
|
||||
]
|
||||
|
||||
# POST /auth/login_flow
|
||||
|
||||
Create a login flow. Will return the first step of the flow.
|
||||
|
||||
Pass in parameter 'handler' to specify the auth provider to use. Auth providers
|
||||
are identified by type and id.
|
||||
|
||||
{
|
||||
"handler": ["local_provider", null]
|
||||
}
|
||||
|
||||
Return value will be a step in a data entry flow. See the docs for data entry
|
||||
flow for details.
|
||||
|
||||
{
|
||||
"data_schema": [
|
||||
{"name": "username", "type": "string"},
|
||||
{"name": "password", "type": "string"}
|
||||
],
|
||||
"errors": {},
|
||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||
"handler": ["insecure_example", null],
|
||||
"step_id": "init",
|
||||
"type": "form"
|
||||
}
|
||||
|
||||
# POST /auth/login_flow/{flow_id}
|
||||
|
||||
Progress the flow. Most flows will be 1 page, but could optionally add extra
|
||||
login challenges, like TFA. Once the flow has finished, the returned step will
|
||||
have type "create_entry" and "result" key will contain an authorization code.
|
||||
|
||||
{
|
||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||
"handler": ["insecure_example", null],
|
||||
"result": "411ee2f916e648d691e937ae9344681e",
|
||||
"source": "user",
|
||||
"title": "Example",
|
||||
"type": "create_entry",
|
||||
"version": 1
|
||||
}
|
||||
|
||||
# POST /auth/token
|
||||
|
||||
This is an OAuth2 endpoint for granting tokens. We currently support the grant
|
||||
@@ -69,6 +12,7 @@ be in JSON as it's more readable.
|
||||
Exchange the authorization code retrieved from the login flow for tokens.
|
||||
|
||||
{
|
||||
"client_id": "https://hassbian.local:8123/",
|
||||
"grant_type": "authorization_code",
|
||||
"code": "411ee2f916e648d691e937ae9344681e"
|
||||
}
|
||||
@@ -89,6 +33,7 @@ token.
|
||||
Request a new access token using a refresh token.
|
||||
|
||||
{
|
||||
"client_id": "https://hassbian.local:8123/",
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "IJKLMNOPQRST"
|
||||
}
|
||||
@@ -101,183 +46,283 @@ a limited expiration.
|
||||
"expires_in": 1800,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
|
||||
## Revoking a refresh token
|
||||
|
||||
It is also possible to revoke a refresh token and all access tokens that have
|
||||
ever been granted by that refresh token. Response code will ALWAYS be 200.
|
||||
|
||||
{
|
||||
"token": "IJKLMNOPQRST",
|
||||
"action": "revoke"
|
||||
}
|
||||
|
||||
# Websocket API
|
||||
|
||||
## Get current user
|
||||
|
||||
Send websocket command `auth/current_user` will return current user of the
|
||||
active websocket connection.
|
||||
|
||||
{
|
||||
"id": 10,
|
||||
"type": "auth/current_user",
|
||||
}
|
||||
|
||||
The result payload likes
|
||||
|
||||
{
|
||||
"id": 10,
|
||||
"type": "result",
|
||||
"success": true,
|
||||
"result": {
|
||||
"id": "USER_ID",
|
||||
"name": "John Doe",
|
||||
"is_owner': true,
|
||||
"credentials": [
|
||||
{
|
||||
"auth_provider_type": "homeassistant",
|
||||
"auth_provider_id": null
|
||||
}
|
||||
],
|
||||
"mfa_modules": [
|
||||
{
|
||||
"id": "totp",
|
||||
"name": "TOTP",
|
||||
"enabled": true,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
## Create a long-lived access token
|
||||
|
||||
Send websocket command `auth/long_lived_access_token` will create
|
||||
a long-lived access token for current user. Access token will not be saved in
|
||||
Home Assistant. User need to record the token in secure place.
|
||||
|
||||
{
|
||||
"id": 11,
|
||||
"type": "auth/long_lived_access_token",
|
||||
"client_name": "GPS Logger",
|
||||
"client_icon": null,
|
||||
"lifespan": 365
|
||||
}
|
||||
|
||||
Result will be a long-lived access token:
|
||||
|
||||
{
|
||||
"id": 11,
|
||||
"type": "result",
|
||||
"success": true,
|
||||
"result": "ABCDEFGH"
|
||||
}
|
||||
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import aiohttp.web
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.data_entry_flow import (
|
||||
FlowManagerIndexView, FlowManagerResourceView)
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
from homeassistant.auth.models import User, Credentials, \
|
||||
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_REAL_IP
|
||||
from homeassistant.components.http.ban import log_invalid_auth
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .client import verify_client
|
||||
from . import indieauth
|
||||
from . import login_flow
|
||||
from . import mfa_setup_flow
|
||||
|
||||
DOMAIN = 'auth'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
WS_TYPE_CURRENT_USER = 'auth/current_user'
|
||||
SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_CURRENT_USER,
|
||||
})
|
||||
|
||||
WS_TYPE_LONG_LIVED_ACCESS_TOKEN = 'auth/long_lived_access_token'
|
||||
SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = \
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_LONG_LIVED_ACCESS_TOKEN,
|
||||
vol.Required('lifespan'): int, # days
|
||||
vol.Required('client_name'): str,
|
||||
vol.Optional('client_icon'): str,
|
||||
})
|
||||
|
||||
WS_TYPE_REFRESH_TOKENS = 'auth/refresh_tokens'
|
||||
SCHEMA_WS_REFRESH_TOKENS = \
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_REFRESH_TOKENS,
|
||||
})
|
||||
|
||||
WS_TYPE_DELETE_REFRESH_TOKEN = 'auth/delete_refresh_token'
|
||||
SCHEMA_WS_DELETE_REFRESH_TOKEN = \
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_DELETE_REFRESH_TOKEN,
|
||||
vol.Required('refresh_token_id'): str,
|
||||
})
|
||||
|
||||
RESULT_TYPE_CREDENTIALS = 'credentials'
|
||||
RESULT_TYPE_USER = 'user'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Component to allow users to login."""
|
||||
store_credentials, retrieve_credentials = _create_cred_store()
|
||||
store_result, retrieve_result = _create_auth_code_store()
|
||||
|
||||
hass.http.register_view(AuthProvidersView)
|
||||
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow))
|
||||
hass.http.register_view(
|
||||
LoginFlowResourceView(hass.auth.login_flow, store_credentials))
|
||||
hass.http.register_view(GrantTokenView(retrieve_credentials))
|
||||
hass.http.register_view(LinkUserView(retrieve_credentials))
|
||||
hass.http.register_view(TokenView(retrieve_result))
|
||||
hass.http.register_view(LinkUserView(retrieve_result))
|
||||
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_CURRENT_USER, websocket_current_user,
|
||||
SCHEMA_WS_CURRENT_USER
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_LONG_LIVED_ACCESS_TOKEN,
|
||||
websocket_create_long_lived_access_token,
|
||||
SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_REFRESH_TOKENS,
|
||||
websocket_refresh_tokens,
|
||||
SCHEMA_WS_REFRESH_TOKENS
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_DELETE_REFRESH_TOKEN,
|
||||
websocket_delete_refresh_token,
|
||||
SCHEMA_WS_DELETE_REFRESH_TOKEN
|
||||
)
|
||||
|
||||
await login_flow.async_setup(hass, store_result)
|
||||
await mfa_setup_flow.async_setup(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AuthProvidersView(HomeAssistantView):
|
||||
"""View to get available auth providers."""
|
||||
|
||||
url = '/auth/providers'
|
||||
name = 'api:auth:providers'
|
||||
requires_auth = False
|
||||
|
||||
@verify_client
|
||||
async def get(self, request, client):
|
||||
"""Get available auth providers."""
|
||||
return self.json([{
|
||||
'name': provider.name,
|
||||
'id': provider.id,
|
||||
'type': provider.type,
|
||||
} for provider in request.app['hass'].auth.async_auth_providers])
|
||||
|
||||
|
||||
class LoginFlowIndexView(FlowManagerIndexView):
|
||||
"""View to create a config flow."""
|
||||
|
||||
url = '/auth/login_flow'
|
||||
name = 'api:auth:login_flow'
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request):
|
||||
"""Do not allow index of flows in progress."""
|
||||
return aiohttp.web.Response(status=405)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
@verify_client
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('handler'): vol.Any(str, list),
|
||||
vol.Required('redirect_uri'): str,
|
||||
}))
|
||||
async def post(self, request, client, data):
|
||||
"""Create a new login flow."""
|
||||
if data['redirect_uri'] not in client.redirect_uris:
|
||||
return self.json_message('invalid redirect uri', )
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
return await super().post(request)
|
||||
|
||||
|
||||
class LoginFlowResourceView(FlowManagerResourceView):
|
||||
"""View to interact with the flow manager."""
|
||||
|
||||
url = '/auth/login_flow/{flow_id}'
|
||||
name = 'api:auth:login_flow:resource'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, flow_mgr, store_credentials):
|
||||
"""Initialize the login flow resource view."""
|
||||
super().__init__(flow_mgr)
|
||||
self._store_credentials = store_credentials
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
async def get(self, request):
|
||||
"""Do not allow getting status of a flow in progress."""
|
||||
return self.json_message('Invalid flow specified', 404)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
@verify_client
|
||||
@RequestDataValidator(vol.Schema(dict), allow_empty=True)
|
||||
async def post(self, request, client, flow_id, data):
|
||||
"""Handle progressing a login flow request."""
|
||||
try:
|
||||
result = await self._flow_mgr.async_configure(flow_id, data)
|
||||
except data_entry_flow.UnknownFlow:
|
||||
return self.json_message('Invalid flow specified', 404)
|
||||
except vol.Invalid:
|
||||
return self.json_message('User input malformed', 400)
|
||||
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
return self.json(self._prepare_result_json(result))
|
||||
|
||||
result.pop('data')
|
||||
result['result'] = self._store_credentials(client.id, result['result'])
|
||||
|
||||
return self.json(result)
|
||||
|
||||
|
||||
class GrantTokenView(HomeAssistantView):
|
||||
"""View to grant tokens."""
|
||||
class TokenView(HomeAssistantView):
|
||||
"""View to issue or revoke tokens."""
|
||||
|
||||
url = '/auth/token'
|
||||
name = 'api:auth:token'
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
|
||||
def __init__(self, retrieve_credentials):
|
||||
"""Initialize the grant token view."""
|
||||
self._retrieve_credentials = retrieve_credentials
|
||||
def __init__(self, retrieve_user):
|
||||
"""Initialize the token view."""
|
||||
self._retrieve_user = retrieve_user
|
||||
|
||||
@verify_client
|
||||
async def post(self, request, client):
|
||||
@log_invalid_auth
|
||||
async def post(self, request):
|
||||
"""Grant a token."""
|
||||
hass = request.app['hass']
|
||||
data = await request.post()
|
||||
|
||||
grant_type = data.get('grant_type')
|
||||
|
||||
# IndieAuth 6.3.5
|
||||
# The revocation endpoint is the same as the token endpoint.
|
||||
# The revocation request includes an additional parameter,
|
||||
# action=revoke.
|
||||
if data.get('action') == 'revoke':
|
||||
return await self._async_handle_revoke_token(hass, data)
|
||||
|
||||
if grant_type == 'authorization_code':
|
||||
return await self._async_handle_auth_code(
|
||||
hass, client.id, data)
|
||||
hass, data, str(request[KEY_REAL_IP]))
|
||||
|
||||
elif grant_type == 'refresh_token':
|
||||
if grant_type == 'refresh_token':
|
||||
return await self._async_handle_refresh_token(
|
||||
hass, client.id, data)
|
||||
hass, data, str(request[KEY_REAL_IP]))
|
||||
|
||||
return self.json({
|
||||
'error': 'unsupported_grant_type',
|
||||
}, status_code=400)
|
||||
|
||||
async def _async_handle_auth_code(self, hass, client_id, data):
|
||||
async def _async_handle_revoke_token(self, hass, data):
|
||||
"""Handle revoke token request."""
|
||||
# OAuth 2.0 Token Revocation [RFC7009]
|
||||
# 2.2 The authorization server responds with HTTP status code 200
|
||||
# if the token has been revoked successfully or if the client
|
||||
# submitted an invalid token.
|
||||
token = data.get('token')
|
||||
|
||||
if token is None:
|
||||
return web.Response(status=200)
|
||||
|
||||
refresh_token = await hass.auth.async_get_refresh_token_by_token(token)
|
||||
|
||||
if refresh_token is None:
|
||||
return web.Response(status=200)
|
||||
|
||||
await hass.auth.async_remove_refresh_token(refresh_token)
|
||||
return web.Response(status=200)
|
||||
|
||||
async def _async_handle_auth_code(self, hass, data, remote_addr):
|
||||
"""Handle authorization code request."""
|
||||
client_id = data.get('client_id')
|
||||
if client_id is None or not indieauth.verify_client_id(client_id):
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
'error_description': 'Invalid client id',
|
||||
}, status_code=400)
|
||||
|
||||
code = data.get('code')
|
||||
|
||||
if code is None:
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
'error_description': 'Invalid code',
|
||||
}, status_code=400)
|
||||
|
||||
credentials = self._retrieve_credentials(client_id, code)
|
||||
user = self._retrieve_user(client_id, RESULT_TYPE_USER, code)
|
||||
|
||||
if credentials is None:
|
||||
if user is None or not isinstance(user, User):
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
'error_description': 'Invalid code',
|
||||
}, status_code=400)
|
||||
|
||||
user = await hass.auth.async_get_or_create_user(credentials)
|
||||
# refresh user
|
||||
user = await hass.auth.async_get_user(user.id)
|
||||
|
||||
if not user.is_active:
|
||||
return self.json({
|
||||
'error': 'access_denied',
|
||||
'error_description': 'User is not active',
|
||||
}, status_code=403)
|
||||
|
||||
refresh_token = await hass.auth.async_create_refresh_token(user,
|
||||
client_id)
|
||||
access_token = hass.auth.async_create_access_token(refresh_token)
|
||||
access_token = hass.auth.async_create_access_token(
|
||||
refresh_token, remote_addr)
|
||||
|
||||
return self.json({
|
||||
'access_token': access_token.token,
|
||||
'access_token': access_token,
|
||||
'token_type': 'Bearer',
|
||||
'refresh_token': refresh_token.token,
|
||||
'expires_in':
|
||||
int(refresh_token.access_token_expiration.total_seconds()),
|
||||
})
|
||||
|
||||
async def _async_handle_refresh_token(self, hass, client_id, data):
|
||||
async def _async_handle_refresh_token(self, hass, data, remote_addr):
|
||||
"""Handle authorization code request."""
|
||||
client_id = data.get('client_id')
|
||||
if client_id is not None and not indieauth.verify_client_id(client_id):
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
'error_description': 'Invalid client id',
|
||||
}, status_code=400)
|
||||
|
||||
token = data.get('refresh_token')
|
||||
|
||||
if token is None:
|
||||
@@ -285,17 +330,23 @@ class GrantTokenView(HomeAssistantView):
|
||||
'error': 'invalid_request',
|
||||
}, status_code=400)
|
||||
|
||||
refresh_token = await hass.auth.async_get_refresh_token(token)
|
||||
refresh_token = await hass.auth.async_get_refresh_token_by_token(token)
|
||||
|
||||
if refresh_token is None or refresh_token.client_id != client_id:
|
||||
if refresh_token is None:
|
||||
return self.json({
|
||||
'error': 'invalid_grant',
|
||||
}, status_code=400)
|
||||
|
||||
access_token = hass.auth.async_create_access_token(refresh_token)
|
||||
if refresh_token.client_id != client_id:
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
}, status_code=400)
|
||||
|
||||
access_token = hass.auth.async_create_access_token(
|
||||
refresh_token, remote_addr)
|
||||
|
||||
return self.json({
|
||||
'access_token': access_token.token,
|
||||
'access_token': access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in':
|
||||
int(refresh_token.access_token_expiration.total_seconds()),
|
||||
@@ -322,7 +373,7 @@ class LinkUserView(HomeAssistantView):
|
||||
user = request['hass_user']
|
||||
|
||||
credentials = self._retrieve_credentials(
|
||||
data['client_id'], data['code'])
|
||||
data['client_id'], RESULT_TYPE_CREDENTIALS, data['code'])
|
||||
|
||||
if credentials is None:
|
||||
return self.json_message('Invalid code', status_code=400)
|
||||
@@ -332,20 +383,134 @@ class LinkUserView(HomeAssistantView):
|
||||
|
||||
|
||||
@callback
|
||||
def _create_cred_store():
|
||||
"""Create a credential store."""
|
||||
temp_credentials = {}
|
||||
def _create_auth_code_store():
|
||||
"""Create an in memory store."""
|
||||
temp_results = {}
|
||||
|
||||
@callback
|
||||
def store_credentials(client_id, credentials):
|
||||
"""Store credentials and return a code to retrieve it."""
|
||||
def store_result(client_id, result):
|
||||
"""Store flow result and return a code to retrieve it."""
|
||||
if isinstance(result, User):
|
||||
result_type = RESULT_TYPE_USER
|
||||
elif isinstance(result, Credentials):
|
||||
result_type = RESULT_TYPE_CREDENTIALS
|
||||
else:
|
||||
raise ValueError('result has to be either User or Credentials')
|
||||
|
||||
code = uuid.uuid4().hex
|
||||
temp_credentials[(client_id, code)] = credentials
|
||||
temp_results[(client_id, result_type, code)] = \
|
||||
(dt_util.utcnow(), result_type, result)
|
||||
return code
|
||||
|
||||
@callback
|
||||
def retrieve_credentials(client_id, code):
|
||||
"""Retrieve credentials."""
|
||||
return temp_credentials.pop((client_id, code), None)
|
||||
def retrieve_result(client_id, result_type, code):
|
||||
"""Retrieve flow result."""
|
||||
key = (client_id, result_type, code)
|
||||
|
||||
return store_credentials, retrieve_credentials
|
||||
if key not in temp_results:
|
||||
return None
|
||||
|
||||
created, _, result = temp_results.pop(key)
|
||||
|
||||
# OAuth 4.2.1
|
||||
# The authorization code MUST expire shortly after it is issued to
|
||||
# mitigate the risk of leaks. A maximum authorization code lifetime of
|
||||
# 10 minutes is RECOMMENDED.
|
||||
if dt_util.utcnow() - created < timedelta(minutes=10):
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
return store_result, retrieve_result
|
||||
|
||||
|
||||
@websocket_api.ws_require_user()
|
||||
@callback
|
||||
def websocket_current_user(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
|
||||
"""Return the current user."""
|
||||
async def async_get_current_user(user):
|
||||
"""Get current user."""
|
||||
enabled_modules = await hass.auth.async_get_enabled_mfa(user)
|
||||
|
||||
connection.send_message_outside(
|
||||
websocket_api.result_message(msg['id'], {
|
||||
'id': user.id,
|
||||
'name': user.name,
|
||||
'is_owner': user.is_owner,
|
||||
'credentials': [{'auth_provider_type': c.auth_provider_type,
|
||||
'auth_provider_id': c.auth_provider_id}
|
||||
for c in user.credentials],
|
||||
'mfa_modules': [{
|
||||
'id': module.id,
|
||||
'name': module.name,
|
||||
'enabled': module.id in enabled_modules,
|
||||
} for module in hass.auth.auth_mfa_modules],
|
||||
}))
|
||||
|
||||
hass.async_create_task(async_get_current_user(connection.user))
|
||||
|
||||
|
||||
@websocket_api.ws_require_user()
|
||||
@callback
|
||||
def websocket_create_long_lived_access_token(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
|
||||
"""Create or a long-lived access token."""
|
||||
async def async_create_long_lived_access_token(user):
|
||||
"""Create or a long-lived access token."""
|
||||
refresh_token = await hass.auth.async_create_refresh_token(
|
||||
user,
|
||||
client_name=msg['client_name'],
|
||||
client_icon=msg.get('client_icon'),
|
||||
token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
|
||||
access_token_expiration=timedelta(days=msg['lifespan']))
|
||||
|
||||
access_token = hass.auth.async_create_access_token(
|
||||
refresh_token)
|
||||
|
||||
connection.send_message_outside(
|
||||
websocket_api.result_message(msg['id'], access_token))
|
||||
|
||||
hass.async_create_task(
|
||||
async_create_long_lived_access_token(connection.user))
|
||||
|
||||
|
||||
@websocket_api.ws_require_user()
|
||||
@callback
|
||||
def websocket_refresh_tokens(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
|
||||
"""Return metadata of users refresh tokens."""
|
||||
current_id = connection.request.get('refresh_token_id')
|
||||
connection.to_write.put_nowait(websocket_api.result_message(msg['id'], [{
|
||||
'id': refresh.id,
|
||||
'client_id': refresh.client_id,
|
||||
'client_name': refresh.client_name,
|
||||
'client_icon': refresh.client_icon,
|
||||
'type': refresh.token_type,
|
||||
'created_at': refresh.created_at,
|
||||
'is_current': refresh.id == current_id,
|
||||
'last_used_at': refresh.last_used_at,
|
||||
'last_used_ip': refresh.last_used_ip,
|
||||
} for refresh in connection.user.refresh_tokens.values()]))
|
||||
|
||||
|
||||
@websocket_api.ws_require_user()
|
||||
@callback
|
||||
def websocket_delete_refresh_token(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
|
||||
"""Handle a delete refresh token request."""
|
||||
async def async_delete_refresh_token(user, refresh_token_id):
|
||||
"""Delete a refresh token."""
|
||||
refresh_token = connection.user.refresh_tokens.get(refresh_token_id)
|
||||
|
||||
if refresh_token is None:
|
||||
return websocket_api.error_message(
|
||||
msg['id'], 'invalid_token_id', 'Received invalid token')
|
||||
|
||||
await hass.auth.async_remove_refresh_token(refresh_token)
|
||||
|
||||
connection.send_message_outside(
|
||||
websocket_api.result_message(msg['id'], {}))
|
||||
|
||||
hass.async_create_task(
|
||||
async_delete_refresh_token(connection.user, msg['refresh_token_id']))
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Helpers to resolve client ID/secret."""
|
||||
import base64
|
||||
from functools import wraps
|
||||
import hmac
|
||||
|
||||
import aiohttp.hdrs
|
||||
|
||||
|
||||
def verify_client(method):
|
||||
"""Decorator to verify client id/secret on requests."""
|
||||
@wraps(method)
|
||||
async def wrapper(view, request, *args, **kwargs):
|
||||
"""Verify client id/secret before doing request."""
|
||||
client = await _verify_client(request)
|
||||
|
||||
if client is None:
|
||||
return view.json({
|
||||
'error': 'invalid_client',
|
||||
}, status_code=401)
|
||||
|
||||
return await method(
|
||||
view, request, *args, **kwargs, client=client)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def _verify_client(request):
|
||||
"""Method to verify the client id/secret in consistent time.
|
||||
|
||||
By using a consistent time for looking up client id and comparing the
|
||||
secret, we prevent attacks by malicious actors trying different client ids
|
||||
and are able to derive from the time it takes to process the request if
|
||||
they guessed the client id correctly.
|
||||
"""
|
||||
if aiohttp.hdrs.AUTHORIZATION not in request.headers:
|
||||
return None
|
||||
|
||||
auth_type, auth_value = \
|
||||
request.headers.get(aiohttp.hdrs.AUTHORIZATION).split(' ', 1)
|
||||
|
||||
if auth_type != 'Basic':
|
||||
return None
|
||||
|
||||
decoded = base64.b64decode(auth_value).decode('utf-8')
|
||||
try:
|
||||
client_id, client_secret = decoded.split(':', 1)
|
||||
except ValueError:
|
||||
# If no ':' in decoded
|
||||
client_id, client_secret = decoded, None
|
||||
|
||||
return await async_secure_get_client(
|
||||
request.app['hass'], client_id, client_secret)
|
||||
|
||||
|
||||
async def async_secure_get_client(hass, client_id, client_secret):
|
||||
"""Get a client id/secret in consistent time."""
|
||||
client = await hass.auth.async_get_client(client_id)
|
||||
|
||||
if client is None:
|
||||
if client_secret is not None:
|
||||
# Still do a compare so we run same time as if a client was found.
|
||||
hmac.compare_digest(client_secret.encode('utf-8'),
|
||||
client_secret.encode('utf-8'))
|
||||
return None
|
||||
|
||||
if client.secret is None:
|
||||
return client
|
||||
|
||||
elif client_secret is None:
|
||||
# Still do a compare so we run same time as if a secret was passed.
|
||||
hmac.compare_digest(client.secret.encode('utf-8'),
|
||||
client.secret.encode('utf-8'))
|
||||
return None
|
||||
|
||||
elif hmac.compare_digest(client_secret.encode('utf-8'),
|
||||
client.secret.encode('utf-8')):
|
||||
return client
|
||||
|
||||
return None
|
||||
193
homeassistant/components/auth/indieauth.py
Normal file
193
homeassistant/components/auth/indieauth.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Helpers to resolve client ID/secret."""
|
||||
import asyncio
|
||||
from html.parser import HTMLParser
|
||||
from ipaddress import ip_address, ip_network
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
|
||||
# IP addresses of loopback interfaces
|
||||
ALLOWED_IPS = (
|
||||
ip_address('127.0.0.1'),
|
||||
ip_address('::1'),
|
||||
)
|
||||
|
||||
# RFC1918 - Address allocation for Private Internets
|
||||
ALLOWED_NETWORKS = (
|
||||
ip_network('10.0.0.0/8'),
|
||||
ip_network('172.16.0.0/12'),
|
||||
ip_network('192.168.0.0/16'),
|
||||
)
|
||||
|
||||
|
||||
async def verify_redirect_uri(hass, client_id, redirect_uri):
|
||||
"""Verify that the client and redirect uri match."""
|
||||
try:
|
||||
client_id_parts = _parse_client_id(client_id)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
redirect_parts = _parse_url(redirect_uri)
|
||||
|
||||
# Verify redirect url and client url have same scheme and domain.
|
||||
is_valid = (
|
||||
client_id_parts.scheme == redirect_parts.scheme and
|
||||
client_id_parts.netloc == redirect_parts.netloc
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
return True
|
||||
|
||||
# IndieAuth 4.2.2 allows for redirect_uri to be on different domain
|
||||
# but needs to be specified in link tag when fetching `client_id`.
|
||||
redirect_uris = await fetch_redirect_uris(hass, client_id)
|
||||
return redirect_uri in redirect_uris
|
||||
|
||||
|
||||
class LinkTagParser(HTMLParser):
|
||||
"""Parser to find link tags."""
|
||||
|
||||
def __init__(self, rel):
|
||||
"""Initialize a link tag parser."""
|
||||
super().__init__()
|
||||
self.rel = rel
|
||||
self.found = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
"""Handle finding a start tag."""
|
||||
if tag != 'link':
|
||||
return
|
||||
|
||||
attrs = dict(attrs)
|
||||
|
||||
if attrs.get('rel') == self.rel:
|
||||
self.found.append(attrs.get('href'))
|
||||
|
||||
|
||||
async def fetch_redirect_uris(hass, url):
|
||||
"""Find link tag with redirect_uri values.
|
||||
|
||||
IndieAuth 4.2.2
|
||||
|
||||
The client SHOULD publish one or more <link> tags or Link HTTP headers with
|
||||
a rel attribute of redirect_uri at the client_id URL.
|
||||
|
||||
We limit to the first 10kB of the page.
|
||||
|
||||
We do not implement extracting redirect uris from headers.
|
||||
"""
|
||||
parser = LinkTagParser('redirect_uri')
|
||||
chunks = 0
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=5) as resp:
|
||||
async for data in resp.content.iter_chunked(1024):
|
||||
parser.feed(data.decode())
|
||||
chunks += 1
|
||||
|
||||
if chunks == 10:
|
||||
break
|
||||
|
||||
except (asyncio.TimeoutError, ClientError):
|
||||
pass
|
||||
|
||||
# Authorization endpoints verifying that a redirect_uri is allowed for use
|
||||
# by a client MUST look for an exact match of the given redirect_uri in the
|
||||
# request against the list of redirect_uris discovered after resolving any
|
||||
# relative URLs.
|
||||
return [urljoin(url, found) for found in parser.found]
|
||||
|
||||
|
||||
def verify_client_id(client_id):
|
||||
"""Verify that the client id is valid."""
|
||||
try:
|
||||
_parse_client_id(client_id)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_url(url):
|
||||
"""Parse a url in parts and canonicalize according to IndieAuth."""
|
||||
parts = urlparse(url)
|
||||
|
||||
# Canonicalize a url according to IndieAuth 3.2.
|
||||
|
||||
# SHOULD convert the hostname to lowercase
|
||||
parts = parts._replace(netloc=parts.netloc.lower())
|
||||
|
||||
# If a URL with no path component is ever encountered,
|
||||
# it MUST be treated as if it had the path /.
|
||||
if parts.path == '':
|
||||
parts = parts._replace(path='/')
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _parse_client_id(client_id):
|
||||
"""Test if client id is a valid URL according to IndieAuth section 3.2.
|
||||
|
||||
https://indieauth.spec.indieweb.org/#client-identifier
|
||||
"""
|
||||
parts = _parse_url(client_id)
|
||||
|
||||
# Client identifier URLs
|
||||
# MUST have either an https or http scheme
|
||||
if parts.scheme not in ('http', 'https'):
|
||||
raise ValueError()
|
||||
|
||||
# MUST contain a path component
|
||||
# Handled by url canonicalization.
|
||||
|
||||
# MUST NOT contain single-dot or double-dot path segments
|
||||
if any(segment in ('.', '..') for segment in parts.path.split('/')):
|
||||
raise ValueError(
|
||||
'Client ID cannot contain single-dot or double-dot path segments')
|
||||
|
||||
# MUST NOT contain a fragment component
|
||||
if parts.fragment != '':
|
||||
raise ValueError('Client ID cannot contain a fragment')
|
||||
|
||||
# MUST NOT contain a username or password component
|
||||
if parts.username is not None:
|
||||
raise ValueError('Client ID cannot contain username')
|
||||
|
||||
if parts.password is not None:
|
||||
raise ValueError('Client ID cannot contain password')
|
||||
|
||||
# MAY contain a port
|
||||
try:
|
||||
# parts raises ValueError when port cannot be parsed as int
|
||||
parts.port
|
||||
except ValueError:
|
||||
raise ValueError('Client ID contains invalid port')
|
||||
|
||||
# Additionally, hostnames
|
||||
# MUST be domain names or a loopback interface and
|
||||
# MUST NOT be IPv4 or IPv6 addresses except for IPv4 127.0.0.1
|
||||
# or IPv6 [::1]
|
||||
|
||||
# We are not goint to follow the spec here. We are going to allow
|
||||
# any internal network IP to be used inside a client id.
|
||||
|
||||
address = None
|
||||
|
||||
try:
|
||||
netloc = parts.netloc
|
||||
|
||||
# Strip the [, ] from ipv6 addresses before parsing
|
||||
if netloc[0] == '[' and netloc[-1] == ']':
|
||||
netloc = netloc[1:-1]
|
||||
|
||||
address = ip_address(netloc)
|
||||
except ValueError:
|
||||
# Not an ip address
|
||||
pass
|
||||
|
||||
if (address is None or
|
||||
address in ALLOWED_IPS or
|
||||
any(address in network for network in ALLOWED_NETWORKS)):
|
||||
return parts
|
||||
|
||||
raise ValueError('Hostname should be a domain name or local IP address')
|
||||
247
homeassistant/components/auth/login_flow.py
Normal file
247
homeassistant/components/auth/login_flow.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""HTTP views handle login flow.
|
||||
|
||||
# GET /auth/providers
|
||||
|
||||
Return a list of auth providers. Example:
|
||||
|
||||
[
|
||||
{
|
||||
"name": "Local",
|
||||
"id": null,
|
||||
"type": "local_provider",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# POST /auth/login_flow
|
||||
|
||||
Create a login flow. Will return the first step of the flow.
|
||||
|
||||
Pass in parameter 'client_id' and 'redirect_url' validate by indieauth.
|
||||
|
||||
Pass in parameter 'handler' to specify the auth provider to use. Auth providers
|
||||
are identified by type and id.
|
||||
|
||||
And optional parameter 'type' has to set as 'link_user' if login flow used for
|
||||
link credential to exist user. Default 'type' is 'authorize'.
|
||||
|
||||
{
|
||||
"client_id": "https://hassbian.local:8123/",
|
||||
"handler": ["local_provider", null],
|
||||
"redirect_url": "https://hassbian.local:8123/",
|
||||
"type': "authorize"
|
||||
}
|
||||
|
||||
Return value will be a step in a data entry flow. See the docs for data entry
|
||||
flow for details.
|
||||
|
||||
{
|
||||
"data_schema": [
|
||||
{"name": "username", "type": "string"},
|
||||
{"name": "password", "type": "string"}
|
||||
],
|
||||
"errors": {},
|
||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||
"handler": ["insecure_example", null],
|
||||
"step_id": "init",
|
||||
"type": "form"
|
||||
}
|
||||
|
||||
|
||||
# POST /auth/login_flow/{flow_id}
|
||||
|
||||
Progress the flow. Most flows will be 1 page, but could optionally add extra
|
||||
login challenges, like TFA. Once the flow has finished, the returned step will
|
||||
have type "create_entry" and "result" key will contain an authorization code.
|
||||
The authorization code associated with an authorized user by default, it will
|
||||
associate with an credential if "type" set to "link_user" in
|
||||
"/auth/login_flow"
|
||||
|
||||
{
|
||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||
"handler": ["insecure_example", null],
|
||||
"result": "411ee2f916e648d691e937ae9344681e",
|
||||
"title": "Example",
|
||||
"type": "create_entry",
|
||||
"version": 1
|
||||
}
|
||||
"""
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.http import KEY_REAL_IP
|
||||
from homeassistant.components.http.ban import process_wrong_login, \
|
||||
log_invalid_auth
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
from . import indieauth
|
||||
|
||||
|
||||
async def async_setup(hass, store_result):
|
||||
"""Component to allow users to login."""
|
||||
hass.http.register_view(AuthProvidersView)
|
||||
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow))
|
||||
hass.http.register_view(
|
||||
LoginFlowResourceView(hass.auth.login_flow, store_result))
|
||||
|
||||
|
||||
class AuthProvidersView(HomeAssistantView):
|
||||
"""View to get available auth providers."""
|
||||
|
||||
url = '/auth/providers'
|
||||
name = 'api:auth:providers'
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request):
|
||||
"""Get available auth providers."""
|
||||
hass = request.app['hass']
|
||||
|
||||
if not hass.components.onboarding.async_is_onboarded():
|
||||
return self.json_message(
|
||||
message='Onboarding not finished',
|
||||
status_code=400,
|
||||
message_code='onboarding_required'
|
||||
)
|
||||
|
||||
return self.json([{
|
||||
'name': provider.name,
|
||||
'id': provider.id,
|
||||
'type': provider.type,
|
||||
} for provider in hass.auth.auth_providers])
|
||||
|
||||
|
||||
def _prepare_result_json(result):
|
||||
"""Convert result to JSON."""
|
||||
if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
data = result.copy()
|
||||
data.pop('result')
|
||||
data.pop('data')
|
||||
return data
|
||||
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_FORM:
|
||||
return result
|
||||
|
||||
import voluptuous_serialize
|
||||
|
||||
data = result.copy()
|
||||
|
||||
schema = data['data_schema']
|
||||
if schema is None:
|
||||
data['data_schema'] = []
|
||||
else:
|
||||
data['data_schema'] = voluptuous_serialize.convert(schema)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class LoginFlowIndexView(HomeAssistantView):
|
||||
"""View to create a config flow."""
|
||||
|
||||
url = '/auth/login_flow'
|
||||
name = 'api:auth:login_flow'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, flow_mgr):
|
||||
"""Initialize the flow manager index view."""
|
||||
self._flow_mgr = flow_mgr
|
||||
|
||||
async def get(self, request):
|
||||
"""Do not allow index of flows in progress."""
|
||||
return web.Response(status=405)
|
||||
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('client_id'): str,
|
||||
vol.Required('handler'): vol.Any(str, list),
|
||||
vol.Required('redirect_uri'): str,
|
||||
vol.Optional('type', default='authorize'): str,
|
||||
}))
|
||||
@log_invalid_auth
|
||||
async def post(self, request, data):
|
||||
"""Create a new login flow."""
|
||||
if not await indieauth.verify_redirect_uri(
|
||||
request.app['hass'], data['client_id'], data['redirect_uri']):
|
||||
return self.json_message('invalid client id or redirect uri', 400)
|
||||
|
||||
if isinstance(data['handler'], list):
|
||||
handler = tuple(data['handler'])
|
||||
else:
|
||||
handler = data['handler']
|
||||
|
||||
try:
|
||||
result = await self._flow_mgr.async_init(
|
||||
handler, context={
|
||||
'ip_address': request[KEY_REAL_IP],
|
||||
'credential_only': data.get('type') == 'link_user',
|
||||
})
|
||||
except data_entry_flow.UnknownHandler:
|
||||
return self.json_message('Invalid handler specified', 404)
|
||||
except data_entry_flow.UnknownStep:
|
||||
return self.json_message('Handler does not support init', 400)
|
||||
|
||||
return self.json(_prepare_result_json(result))
|
||||
|
||||
|
||||
class LoginFlowResourceView(HomeAssistantView):
|
||||
"""View to interact with the flow manager."""
|
||||
|
||||
url = '/auth/login_flow/{flow_id}'
|
||||
name = 'api:auth:login_flow:resource'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, flow_mgr, store_result):
|
||||
"""Initialize the login flow resource view."""
|
||||
self._flow_mgr = flow_mgr
|
||||
self._store_result = store_result
|
||||
|
||||
async def get(self, request):
|
||||
"""Do not allow getting status of a flow in progress."""
|
||||
return self.json_message('Invalid flow specified', 404)
|
||||
|
||||
@RequestDataValidator(vol.Schema({
|
||||
'client_id': str
|
||||
}, extra=vol.ALLOW_EXTRA))
|
||||
@log_invalid_auth
|
||||
async def post(self, request, flow_id, data):
|
||||
"""Handle progressing a login flow request."""
|
||||
client_id = data.pop('client_id')
|
||||
|
||||
if not indieauth.verify_client_id(client_id):
|
||||
return self.json_message('Invalid client id', 400)
|
||||
|
||||
try:
|
||||
# do not allow change ip during login flow
|
||||
for flow in self._flow_mgr.async_progress():
|
||||
if (flow['flow_id'] == flow_id and
|
||||
flow['context']['ip_address'] !=
|
||||
request.get(KEY_REAL_IP)):
|
||||
return self.json_message('IP address changed', 400)
|
||||
|
||||
result = await self._flow_mgr.async_configure(flow_id, data)
|
||||
except data_entry_flow.UnknownFlow:
|
||||
return self.json_message('Invalid flow specified', 404)
|
||||
except vol.Invalid:
|
||||
return self.json_message('User input malformed', 400)
|
||||
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
# @log_invalid_auth does not work here since it returns HTTP 200
|
||||
# need manually log failed login attempts
|
||||
if (result.get('errors') is not None and
|
||||
result['errors'].get('base') in ['invalid_auth',
|
||||
'invalid_code']):
|
||||
await process_wrong_login(request)
|
||||
return self.json(_prepare_result_json(result))
|
||||
|
||||
result.pop('data')
|
||||
result['result'] = self._store_result(client_id, result['result'])
|
||||
|
||||
return self.json(result)
|
||||
|
||||
async def delete(self, request, flow_id):
|
||||
"""Cancel a flow in progress."""
|
||||
try:
|
||||
self._flow_mgr.async_abort(flow_id)
|
||||
except data_entry_flow.UnknownFlow:
|
||||
return self.json_message('Invalid flow specified', 404)
|
||||
|
||||
return self.json_message('Flow aborted')
|
||||
134
homeassistant/components/auth/mfa_setup_flow.py
Normal file
134
homeassistant/components/auth/mfa_setup_flow.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Helpers to setup multi-factor auth module."""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
|
||||
WS_TYPE_SETUP_MFA = 'auth/setup_mfa'
|
||||
SCHEMA_WS_SETUP_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_SETUP_MFA,
|
||||
vol.Exclusive('mfa_module_id', 'module_or_flow_id'): str,
|
||||
vol.Exclusive('flow_id', 'module_or_flow_id'): str,
|
||||
vol.Optional('user_input'): object,
|
||||
})
|
||||
|
||||
WS_TYPE_DEPOSE_MFA = 'auth/depose_mfa'
|
||||
SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_DEPOSE_MFA,
|
||||
vol.Required('mfa_module_id'): str,
|
||||
})
|
||||
|
||||
DATA_SETUP_FLOW_MGR = 'auth_mfa_setup_flow_manager'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass):
|
||||
"""Init mfa setup flow manager."""
|
||||
async def _async_create_setup_flow(handler, context, data):
|
||||
"""Create a setup flow. hanlder is a mfa module."""
|
||||
mfa_module = hass.auth.get_auth_mfa_module(handler)
|
||||
if mfa_module is None:
|
||||
raise ValueError('Mfa module {} is not found'.format(handler))
|
||||
|
||||
user_id = data.pop('user_id')
|
||||
return await mfa_module.async_setup_flow(user_id)
|
||||
|
||||
async def _async_finish_setup_flow(flow, flow_result):
|
||||
_LOGGER.debug('flow_result: %s', flow_result)
|
||||
return flow_result
|
||||
|
||||
hass.data[DATA_SETUP_FLOW_MGR] = data_entry_flow.FlowManager(
|
||||
hass, _async_create_setup_flow, _async_finish_setup_flow)
|
||||
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA)
|
||||
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.ws_require_user(allow_system_user=False)
|
||||
def websocket_setup_mfa(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
|
||||
"""Return a setup flow for mfa auth module."""
|
||||
async def async_setup_flow(msg):
|
||||
"""Return a setup flow for mfa auth module."""
|
||||
flow_manager = hass.data[DATA_SETUP_FLOW_MGR]
|
||||
|
||||
flow_id = msg.get('flow_id')
|
||||
if flow_id is not None:
|
||||
result = await flow_manager.async_configure(
|
||||
flow_id, msg.get('user_input'))
|
||||
connection.send_message_outside(
|
||||
websocket_api.result_message(
|
||||
msg['id'], _prepare_result_json(result)))
|
||||
return
|
||||
|
||||
mfa_module_id = msg.get('mfa_module_id')
|
||||
mfa_module = hass.auth.get_auth_mfa_module(mfa_module_id)
|
||||
if mfa_module is None:
|
||||
connection.send_message_outside(websocket_api.error_message(
|
||||
msg['id'], 'no_module',
|
||||
'MFA module {} is not found'.format(mfa_module_id)))
|
||||
return
|
||||
|
||||
result = await flow_manager.async_init(
|
||||
mfa_module_id, data={'user_id': connection.user.id})
|
||||
|
||||
connection.send_message_outside(
|
||||
websocket_api.result_message(
|
||||
msg['id'], _prepare_result_json(result)))
|
||||
|
||||
hass.async_create_task(async_setup_flow(msg))
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.ws_require_user(allow_system_user=False)
|
||||
def websocket_depose_mfa(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
|
||||
"""Remove user from mfa module."""
|
||||
async def async_depose(msg):
|
||||
"""Remove user from mfa auth module."""
|
||||
mfa_module_id = msg['mfa_module_id']
|
||||
try:
|
||||
await hass.auth.async_disable_user_mfa(
|
||||
connection.user, msg['mfa_module_id'])
|
||||
except ValueError as err:
|
||||
connection.send_message_outside(websocket_api.error_message(
|
||||
msg['id'], 'disable_failed',
|
||||
'Cannot disable MFA Module {}: {}'.format(
|
||||
mfa_module_id, err)))
|
||||
return
|
||||
|
||||
connection.send_message_outside(
|
||||
websocket_api.result_message(
|
||||
msg['id'], 'done'))
|
||||
|
||||
hass.async_create_task(async_depose(msg))
|
||||
|
||||
|
||||
def _prepare_result_json(result):
|
||||
"""Convert result to JSON."""
|
||||
if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
data = result.copy()
|
||||
return data
|
||||
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_FORM:
|
||||
return result
|
||||
|
||||
import voluptuous_serialize
|
||||
|
||||
data = result.copy()
|
||||
|
||||
schema = data['data_schema']
|
||||
if schema is None:
|
||||
data['data_schema'] = []
|
||||
else:
|
||||
data['data_schema'] = voluptuous_serialize.convert(schema)
|
||||
|
||||
return data
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user