Compare commits
2114 Commits
Last-Pytho
...
0.7.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d1dce2519 | ||
|
|
01d097b9b0 | ||
|
|
7f60f1e662 | ||
|
|
bc6c285945 | ||
|
|
d4d8c9ae65 | ||
|
|
9292891836 | ||
|
|
035df68d6c | ||
|
|
c611be96ad | ||
|
|
d46720ee2c | ||
|
|
c1f464f478 | ||
|
|
8c5759e460 | ||
|
|
8490d6126a | ||
|
|
8b5b580287 | ||
|
|
d35f5b9f97 | ||
|
|
6de64d7695 | ||
|
|
7f1da8b7bc | ||
|
|
58ac4be24c | ||
|
|
b2919c6504 | ||
|
|
3863d2985a | ||
|
|
0180c056e1 | ||
|
|
e6cd9a6dc7 | ||
|
|
33028dd143 | ||
|
|
9bdfa89b7c | ||
|
|
42b80868d4 | ||
|
|
707ca4b752 | ||
|
|
c7d2a09097 | ||
|
|
fb9f83f8ad | ||
|
|
52ebb2fb3b | ||
|
|
ea7ca48ba2 | ||
|
|
ab80af099c | ||
|
|
34531895a0 | ||
|
|
645cd89406 | ||
|
|
cc5217d818 | ||
|
|
e454806669 | ||
|
|
726557b2f6 | ||
|
|
c7e22e6910 | ||
|
|
f66a020bfc | ||
|
|
64a73f6b67 | ||
|
|
ad7f034805 | ||
|
|
76674d4de9 | ||
|
|
0dc9f2a9f8 | ||
|
|
ce47b58a8b | ||
|
|
5d71d5560e | ||
|
|
1dc9bfdf73 | ||
|
|
2eb36c18bd | ||
|
|
5ad27d8cdb | ||
|
|
68c2b539ee | ||
|
|
755234369d | ||
|
|
0a34e8de02 | ||
|
|
52ed25fc21 | ||
|
|
9e866680d4 | ||
|
|
80c89d218b | ||
|
|
4f1bf7b2bf | ||
|
|
e557e355db | ||
|
|
e7320fe969 | ||
|
|
4e3bd5f2a9 | ||
|
|
9a6b2c1831 | ||
|
|
ca0b6ebd99 | ||
|
|
47cd0b20a0 | ||
|
|
98d051f870 | ||
|
|
5f98705100 | ||
|
|
63bf4db969 | ||
|
|
3ec00ce4fe | ||
|
|
74a0e47ba6 | ||
|
|
476e4f0517 | ||
|
|
61fb8271e5 | ||
|
|
5cf9bd7223 | ||
|
|
9f986c55e6 | ||
|
|
94eb54ff00 | ||
|
|
f28b392f1a | ||
|
|
fa71d5fac9 | ||
|
|
21fd53b05d | ||
|
|
b0b3c2f73f | ||
|
|
d660d2b3dc | ||
|
|
a89bfcf342 | ||
|
|
a42347e6e7 | ||
|
|
faee3e8447 | ||
|
|
3625646c34 | ||
|
|
5a562f3db8 | ||
|
|
19705ab40a | ||
|
|
20bf9f7ea1 | ||
|
|
6399c873f9 | ||
|
|
4be1053f1c | ||
|
|
efdd0c9e8a | ||
|
|
8d42e42230 | ||
|
|
4b6878f91c | ||
|
|
90f35b35cd | ||
|
|
082920abe0 | ||
|
|
4a8bbc52e0 | ||
|
|
1674c8309a | ||
|
|
62f016e7d2 | ||
|
|
34e5ecb8ab | ||
|
|
8f95885e3a | ||
|
|
94db1ac142 | ||
|
|
f48e65096a | ||
|
|
3244975489 | ||
|
|
10327795e9 | ||
|
|
bcbb8edd59 | ||
|
|
86270e1a37 | ||
|
|
de7a34b648 | ||
|
|
82a06279de | ||
|
|
62af1fcc57 | ||
|
|
6afe99dcc7 | ||
|
|
b6bf398859 | ||
|
|
48df06d1c0 | ||
|
|
b4ca691822 | ||
|
|
16c2827465 | ||
|
|
e90fd3d654 | ||
|
|
7d0ff6884c | ||
|
|
a9ea8972dd | ||
|
|
a0c1202ad6 | ||
|
|
1bf45c8f33 | ||
|
|
c5094438de | ||
|
|
60d45ebf79 | ||
|
|
5df2a1cf76 | ||
|
|
f5b2fa6fbe | ||
|
|
03b2ced24e | ||
|
|
6c18f264f3 | ||
|
|
cdc371c3ee | ||
|
|
826b3be087 | ||
|
|
f5000d401b | ||
|
|
7443f4faf8 | ||
|
|
3158db9553 | ||
|
|
46a0173e31 | ||
|
|
7e511bcacf | ||
|
|
d7fd2ccdaf | ||
|
|
b2999ae325 | ||
|
|
5033c1fcb7 | ||
|
|
e492be299b | ||
|
|
03e7281406 | ||
|
|
16d75b2981 | ||
|
|
cc7784889a | ||
|
|
d267f0a04c | ||
|
|
a8e0ca6d3f | ||
|
|
f8175adbdc | ||
|
|
6437f6f6b4 | ||
|
|
27bbfbae62 | ||
|
|
2785c373fb | ||
|
|
acddae3747 | ||
|
|
d3e9a22759 | ||
|
|
ca698ff063 | ||
|
|
a866d515f7 | ||
|
|
3af4f267b3 | ||
|
|
bd61555698 | ||
|
|
5027acfda1 | ||
|
|
f0991d63d1 | ||
|
|
34f36479c6 | ||
|
|
506c88dbaf | ||
|
|
98a1addc18 | ||
|
|
30492cc685 | ||
|
|
0d09e2e1df | ||
|
|
81085c7467 | ||
|
|
19d40612e6 | ||
|
|
48306ddbf6 | ||
|
|
a60a9202a5 | ||
|
|
ab81231e6d | ||
|
|
68286dcef8 | ||
|
|
6e96f915f6 | ||
|
|
620a7eadf4 | ||
|
|
2332548cf4 | ||
|
|
dc55525206 | ||
|
|
9318b36ac2 | ||
|
|
46f6653183 | ||
|
|
9736761968 | ||
|
|
6352f10d9e | ||
|
|
2a3b911d7b | ||
|
|
720e5876a7 | ||
|
|
85489010bc | ||
|
|
60d8266ce0 | ||
|
|
e29deb0202 | ||
|
|
268b0f17d0 | ||
|
|
49ce85f2e4 | ||
|
|
1771f8b1b3 | ||
|
|
8cd1c42e80 | ||
|
|
ec1d5e617e | ||
|
|
40651ef2bc | ||
|
|
64741a95b8 | ||
|
|
a24b38aacc | ||
|
|
55f6ff86e4 | ||
|
|
79cdda2bd9 | ||
|
|
35eed93443 | ||
|
|
dd4e1cbd1d | ||
|
|
2084976bc2 | ||
|
|
9019d654d7 | ||
|
|
e0f6239ef3 | ||
|
|
b9e1b3eb99 | ||
|
|
e1a7b8f988 | ||
|
|
be9cfbdeb0 | ||
|
|
a32229b4ce | ||
|
|
7da104af4e | ||
|
|
9d7aef94e0 | ||
|
|
e4c5108c9d | ||
|
|
fc946da5db | ||
|
|
b33714bca3 | ||
|
|
722af9014d | ||
|
|
6abaebb248 | ||
|
|
c01e9bea2b | ||
|
|
5ce4ade737 | ||
|
|
9ce8f385d2 | ||
|
|
d7464aea86 | ||
|
|
071952462c | ||
|
|
ab79b8a541 | ||
|
|
3a3374ed4b | ||
|
|
4d53fa0173 | ||
|
|
5369d8c61c | ||
|
|
6c1f44242c | ||
|
|
4371355be1 | ||
|
|
5bb88909a0 | ||
|
|
4b0c416844 | ||
|
|
737d7c9d22 | ||
|
|
15be5ced9a | ||
|
|
7c549db2d6 | ||
|
|
6e6aa15f7c | ||
|
|
e0c1885a71 | ||
|
|
049cd159ce | ||
|
|
95e05d4fc9 | ||
|
|
bf14067eb0 | ||
|
|
8c77418b6a | ||
|
|
d25a42426a | ||
|
|
3ed102cd88 | ||
|
|
90e2aefd23 | ||
|
|
47af247d6a | ||
|
|
3947ed3c2b | ||
|
|
1a00d4a095 | ||
|
|
ccecc0181d | ||
|
|
e90dbad37e | ||
|
|
8ec0c36457 | ||
|
|
e68cc83e64 | ||
|
|
4ad4d74ed4 | ||
|
|
550f31d4c3 | ||
|
|
7e42b35b62 | ||
|
|
9b96471182 | ||
|
|
3c3eadbef5 | ||
|
|
f375bc527a | ||
|
|
6de04d78ed | ||
|
|
86aea83f64 | ||
|
|
98feb3cd93 | ||
|
|
5af1643297 | ||
|
|
3dcd18af9e | ||
|
|
2fd7b98cab | ||
|
|
90e21791f6 | ||
|
|
9678613a13 | ||
|
|
5de89316b2 | ||
|
|
95eabe7c0e | ||
|
|
9bec0316ea | ||
|
|
61685ea13d | ||
|
|
77b9a12687 | ||
|
|
08f2a67de4 | ||
|
|
58c3b03b79 | ||
|
|
c18294ee76 | ||
|
|
408f0cff78 | ||
|
|
0584c10ef9 | ||
|
|
ae527e9c6f | ||
|
|
1ec5178f66 | ||
|
|
2978e0dabe | ||
|
|
e26f0f7b7d | ||
|
|
2ff2a78e97 | ||
|
|
bb172d8c98 | ||
|
|
acb288f9e7 | ||
|
|
c7565baa6d | ||
|
|
fb29611c15 | ||
|
|
37cd62447e | ||
|
|
5cbcd72912 | ||
|
|
8bba0b88fd | ||
|
|
b1f17c2cd4 | ||
|
|
8017f7f241 | ||
|
|
1a73c1b991 | ||
|
|
039c5cd847 | ||
|
|
3b27bef1ac | ||
|
|
1fc2204ca9 | ||
|
|
834ce5269d | ||
|
|
9ada5e6b2b | ||
|
|
f17ef0327c | ||
|
|
56a151b196 | ||
|
|
6e927d68e5 | ||
|
|
fcad068016 | ||
|
|
e12cc2fbbf | ||
|
|
9588fcc5cc | ||
|
|
a4aa2e4383 | ||
|
|
fe074835f0 | ||
|
|
b2ad8db86b | ||
|
|
20f021d05f | ||
|
|
fc43135ddd | ||
|
|
e86ee9eae7 | ||
|
|
332f7621ce | ||
|
|
68c1dd7cd4 | ||
|
|
fe2a9bb83e | ||
|
|
2f8591205f | ||
|
|
65c3184856 | ||
|
|
0afb6114c5 | ||
|
|
7c7b6ca05c | ||
|
|
2fe8b154f1 | ||
|
|
bf64956265 | ||
|
|
eb11486e76 | ||
|
|
e8c3eaab33 | ||
|
|
fcbeddeb57 | ||
|
|
50b23e1969 | ||
|
|
984f01359c | ||
|
|
d5198d4242 | ||
|
|
f5d1da1d53 | ||
|
|
13ca42e187 | ||
|
|
74eb577c58 | ||
|
|
fe7134b897 | ||
|
|
27845d3fc5 | ||
|
|
6606d2a73c | ||
|
|
6dc877d8de | ||
|
|
b0441aadc4 | ||
|
|
dd71e4fdd1 | ||
|
|
7e066e11ad | ||
|
|
13d40fe6ec | ||
|
|
7e75add144 | ||
|
|
2df26a0d1a | ||
|
|
965730eb60 | ||
|
|
4c0ac6051f | ||
|
|
2a11d02fe4 | ||
|
|
046c5653cb | ||
|
|
f86fcdcaf5 | ||
|
|
835bc1c492 | ||
|
|
de5a2fee83 | ||
|
|
209499e82b | ||
|
|
9b47241a46 | ||
|
|
513f6e9c3c | ||
|
|
9582eae48e | ||
|
|
d4834ff408 | ||
|
|
ce22f3c82d | ||
|
|
246184507c | ||
|
|
3f3b475d76 | ||
|
|
40aa661340 | ||
|
|
6c3a78df30 | ||
|
|
964a1f9aef | ||
|
|
8360ab265c | ||
|
|
e3dcb45879 | ||
|
|
683a80f5f4 | ||
|
|
9904727cde | ||
|
|
e9da02d70c | ||
|
|
b0b88e606c | ||
|
|
57a833f1a7 | ||
|
|
e5e577108c | ||
|
|
51dd718282 | ||
|
|
40340ea832 | ||
|
|
a2ca60159d | ||
|
|
50f5f1860c | ||
|
|
8e89308a15 | ||
|
|
96cfff192a | ||
|
|
067993c8ab | ||
|
|
eef1e65244 | ||
|
|
134c870d2b | ||
|
|
5edc4f148f | ||
|
|
880b5f0ad1 | ||
|
|
804b7669b7 | ||
|
|
81288cc988 | ||
|
|
d4174f5e42 | ||
|
|
cfc23b0091 | ||
|
|
bb42e264cb | ||
|
|
c9bccadc40 | ||
|
|
ab6cb43d5b | ||
|
|
4fa379419d | ||
|
|
b01ff81b47 | ||
|
|
6dcb87c54d | ||
|
|
6cfca09daf | ||
|
|
4eba1250e9 | ||
|
|
d4d798d71f | ||
|
|
3dc1dc6c6a | ||
|
|
473047f3dd | ||
|
|
f5b5d3f65a | ||
|
|
776c7dae07 | ||
|
|
4ccedca3e5 | ||
|
|
d9b97ad5b4 | ||
|
|
de89de890f | ||
|
|
5338b29edf | ||
|
|
395dbe8804 | ||
|
|
f41786d893 | ||
|
|
34dee0c134 | ||
|
|
705238eb78 | ||
|
|
2b6e0da405 | ||
|
|
9d750368ff | ||
|
|
7252861b83 | ||
|
|
db2140782f | ||
|
|
b9f5ec9e2c | ||
|
|
6d9b618f1c | ||
|
|
a459368998 | ||
|
|
cb3f14a862 | ||
|
|
e9367d5369 | ||
|
|
c3dd94ba04 | ||
|
|
6624cfefd6 | ||
|
|
350ed9f764 | ||
|
|
3679a8078a | ||
|
|
a25f7eed2b | ||
|
|
ae058b7847 | ||
|
|
aa74c4e57a | ||
|
|
1b874c603b | ||
|
|
050fe809e1 | ||
|
|
e2b02f2fd2 | ||
|
|
775d3198ae | ||
|
|
21812ba717 | ||
|
|
2d54fdd979 | ||
|
|
e093abc366 | ||
|
|
5d3e929599 | ||
|
|
1ec392a494 | ||
|
|
d719dd72fe | ||
|
|
53b43dc4db | ||
|
|
f21d97d5a2 | ||
|
|
f9b17ab026 | ||
|
|
e88fabbe6d | ||
|
|
6e458114f4 | ||
|
|
0509b478e9 | ||
|
|
73797dad2d | ||
|
|
2e8573b6bd | ||
|
|
3a8119af2b | ||
|
|
9f9755c014 | ||
|
|
117a0018a5 | ||
|
|
34a6524019 | ||
|
|
c971e50a68 | ||
|
|
4d05650744 | ||
|
|
c66f938919 | ||
|
|
fc21451446 | ||
|
|
6a54ccb6b4 | ||
|
|
ad99bd6a41 | ||
|
|
dd23a0b3eb | ||
|
|
89bdead44c | ||
|
|
c68ee2dd0f | ||
|
|
326d23de38 | ||
|
|
3520255b7c | ||
|
|
b0bd1fadac | ||
|
|
4cd01f5516 | ||
|
|
2fb2d5c1d6 | ||
|
|
77892dfa0d | ||
|
|
8a3d9e6b8d | ||
|
|
e61299a46f | ||
|
|
5b69719e95 | ||
|
|
0fb69c5ce4 | ||
|
|
922da1da44 | ||
|
|
da508236e6 | ||
|
|
985f20d281 | ||
|
|
914a6dff5e | ||
|
|
78a555faf5 | ||
|
|
f18928d85b | ||
|
|
e824bc4c55 | ||
|
|
514b8eddb9 | ||
|
|
1ed8e58679 | ||
|
|
e55922eb9e | ||
|
|
e196c136c1 | ||
|
|
1d910f3a84 | ||
|
|
f9cecdee28 | ||
|
|
d0cda964ac | ||
|
|
0f68b9d22b | ||
|
|
c5fc5cba61 | ||
|
|
1a88e48986 | ||
|
|
c7a8f5d6ca | ||
|
|
72426e08b8 | ||
|
|
1c3fa89914 | ||
|
|
a097e9caf2 | ||
|
|
34c4bb585a | ||
|
|
97eb84919b | ||
|
|
2e636f598e | ||
|
|
03d187eceb | ||
|
|
bb9c50d0f1 | ||
|
|
6519e589b5 | ||
|
|
5b7dab6556 | ||
|
|
f9ad12920e | ||
|
|
56151a07a5 | ||
|
|
6c70ef2e6d | ||
|
|
450ca842ca | ||
|
|
9cb735f48e | ||
|
|
d10cecde7c | ||
|
|
14fc4f6f99 | ||
|
|
b8e2bf6b7e | ||
|
|
1ffb4d9a55 | ||
|
|
fd032cf6b7 | ||
|
|
97e19908be | ||
|
|
b9b751d234 | ||
|
|
5533618bd2 | ||
|
|
5b643a8106 | ||
|
|
aa779ff6da | ||
|
|
5099fb7680 | ||
|
|
d2a13da930 | ||
|
|
97076f1ff8 | ||
|
|
3a5a94413b | ||
|
|
03ceb667ba | ||
|
|
40807f1ee0 | ||
|
|
abb8958775 | ||
|
|
ef141ef608 | ||
|
|
3ea91f917d | ||
|
|
ddeccf13af | ||
|
|
53fb46b44b | ||
|
|
a92687bb08 | ||
|
|
e7caac212d | ||
|
|
3bbdc5bcd7 | ||
|
|
c1f172f33a | ||
|
|
18569104fa | ||
|
|
7992882fa3 | ||
|
|
4ca8f184e6 | ||
|
|
58afbecd05 | ||
|
|
5b06e8d25e | ||
|
|
1add38a195 | ||
|
|
ff470c8ffe | ||
|
|
a34b00bc9c | ||
|
|
83440ad718 | ||
|
|
0987a84bf4 | ||
|
|
df4afa5025 | ||
|
|
b41706efe3 | ||
|
|
9eefa67035 | ||
|
|
d421a16ffd | ||
|
|
053e2c2ebc | ||
|
|
6c213f6401 | ||
|
|
74303e4be8 | ||
|
|
794a11db21 | ||
|
|
7c44313203 | ||
|
|
93cd7bfc5d | ||
|
|
3256552675 | ||
|
|
54dd09df29 | ||
|
|
5ed6987067 | ||
|
|
12ead04faa | ||
|
|
3e3ee9184a | ||
|
|
78826648e3 | ||
|
|
ffac067be8 | ||
|
|
5b2b12eed1 | ||
|
|
881901f4d3 | ||
|
|
81190be7ba | ||
|
|
9acf3db435 | ||
|
|
64fff48021 | ||
|
|
98b0367249 | ||
|
|
e95d4cf19c | ||
|
|
e6ac225140 | ||
|
|
b1876d586e | ||
|
|
1958dfd0c1 | ||
|
|
fb4121d4b4 | ||
|
|
4cadc7df96 | ||
|
|
a8b932223f | ||
|
|
335eb10d11 | ||
|
|
028551784a | ||
|
|
b9cca82a45 | ||
|
|
bfbaaa8e9f | ||
|
|
b4fea395de | ||
|
|
62f6576e19 | ||
|
|
41011f0c95 | ||
|
|
7dd7d7a191 | ||
|
|
364d85b6df | ||
|
|
5ba5e0ffb1 | ||
|
|
10054567de | ||
|
|
9ecac9e934 | ||
|
|
81466246cf | ||
|
|
a3812a324c | ||
|
|
ae896f4a33 | ||
|
|
74e4b024c0 | ||
|
|
fc6613ffb1 | ||
|
|
3960a465f1 | ||
|
|
a20ab24ba5 | ||
|
|
f016dec02a | ||
|
|
fcee2c6d33 | ||
|
|
a5a1f30798 | ||
|
|
5aa8814a67 | ||
|
|
7343e33063 | ||
|
|
bea81ddd92 | ||
|
|
4e01e7ca9b | ||
|
|
b750457afa | ||
|
|
bfa3900e6a | ||
|
|
f5b98c86f0 | ||
|
|
0b6358e759 | ||
|
|
893ae15042 | ||
|
|
75b3cc046d | ||
|
|
6fdf9b8d7c | ||
|
|
18e32165a4 | ||
|
|
3839c3d0ef | ||
|
|
c3a9db0a37 | ||
|
|
0a2652630f | ||
|
|
4b31a22a1c | ||
|
|
c49cdf7ffd | ||
|
|
936e20bdf7 | ||
|
|
6b241f8600 | ||
|
|
2a0d459722 | ||
|
|
dfae1a44a6 | ||
|
|
4f8b843a1e | ||
|
|
3f4d5eae1c | ||
|
|
76a8bd3969 | ||
|
|
00f3556c34 | ||
|
|
71e60dcfe9 | ||
|
|
4ef4aa2095 | ||
|
|
1311e00e90 | ||
|
|
1aef768ff0 | ||
|
|
e0db473294 | ||
|
|
17bf27474a | ||
|
|
387769edff | ||
|
|
6582067f66 | ||
|
|
f3868ea744 | ||
|
|
a98ecb6bcc | ||
|
|
ba7b9c625e | ||
|
|
e2cfe2a7d2 | ||
|
|
1cb6077e36 | ||
|
|
47998cff97 | ||
|
|
9811869111 | ||
|
|
6b3b000822 | ||
|
|
c8b88219b7 | ||
|
|
884af889a4 | ||
|
|
c50a47a307 | ||
|
|
e15eb90b33 | ||
|
|
6647894c36 | ||
|
|
c12b7e70d9 | ||
|
|
930036272b | ||
|
|
c194121da6 | ||
|
|
cfc2232c22 | ||
|
|
ab5a569922 | ||
|
|
0a9d82fe6f | ||
|
|
bc5a7564b1 | ||
|
|
047b4abd82 | ||
|
|
917db18b29 | ||
|
|
c078ee4313 | ||
|
|
152fd9cb28 | ||
|
|
089cd0ff8a | ||
|
|
8cda3f8291 | ||
|
|
83794765a4 | ||
|
|
a7889ef628 | ||
|
|
05df84d04f | ||
|
|
93bd238be5 | ||
|
|
a419509893 | ||
|
|
d45a7e2ba4 | ||
|
|
6338f387d2 | ||
|
|
35489998df | ||
|
|
e917479fba | ||
|
|
517d4b35ed | ||
|
|
d69bb8db6e | ||
|
|
b79d0f5404 | ||
|
|
387bdd4a30 | ||
|
|
33b007b7b4 | ||
|
|
3b982e25a5 | ||
|
|
584e67a6d4 | ||
|
|
d0c674b756 | ||
|
|
fd7808e6f4 | ||
|
|
a9a650edb6 | ||
|
|
01ed3b18cc | ||
|
|
e09e78347b | ||
|
|
8a63325abe | ||
|
|
44263752ca | ||
|
|
63e441c73f | ||
|
|
051e2db9b7 | ||
|
|
b418907598 | ||
|
|
ca515615b9 | ||
|
|
7127ddf0a0 | ||
|
|
a23ab44a6d | ||
|
|
b8b5ac0653 | ||
|
|
159411df8b | ||
|
|
c5db42677a | ||
|
|
2bb4a53bd3 | ||
|
|
74308b2677 | ||
|
|
6a830e3b90 | ||
|
|
b84d5760eb | ||
|
|
c471e39fa0 | ||
|
|
d2f01174e7 | ||
|
|
4d5f3da08b | ||
|
|
199a80dcfb | ||
|
|
bf9d067a7d | ||
|
|
ebfec2d1d3 | ||
|
|
dfc7f8b0c6 | ||
|
|
4ab75d58f5 | ||
|
|
b93516197c | ||
|
|
4707b122cc | ||
|
|
086961d109 | ||
|
|
b61b3c611d | ||
|
|
dda399fc76 | ||
|
|
e984eedffd | ||
|
|
6e41eee6cd | ||
|
|
9522eac837 | ||
|
|
8bb189e014 | ||
|
|
1b89a502c4 | ||
|
|
e37869616b | ||
|
|
a0f2f3814b | ||
|
|
ad327b64ed | ||
|
|
f20be1e7f8 | ||
|
|
1b59859681 | ||
|
|
c7ca6e4784 | ||
|
|
d0fc91d84a | ||
|
|
19b62c1088 | ||
|
|
bb848e7fcd | ||
|
|
06a40ad30c | ||
|
|
2289d3e826 | ||
|
|
c900836410 | ||
|
|
bd373a4d25 | ||
|
|
60abaa585c | ||
|
|
27ca611689 | ||
|
|
2a9616e88c | ||
|
|
92fc7eab36 | ||
|
|
3fad4d8cda | ||
|
|
65a4b3c9f8 | ||
|
|
291cc62381 | ||
|
|
eecc51c92d | ||
|
|
f79567f7db | ||
|
|
6e98e55f6a | ||
|
|
969fe1f3b9 | ||
|
|
3bbeeda3ac | ||
|
|
9c63ba1cc3 | ||
|
|
58fcf79340 | ||
|
|
ee73bd7dea | ||
|
|
8f369d0c27 | ||
|
|
55c778ca0a | ||
|
|
f49e5514d6 | ||
|
|
044d43b7c2 | ||
|
|
ae06267072 | ||
|
|
d412b51754 | ||
|
|
4bbe716710 | ||
|
|
277cdbbe00 | ||
|
|
c8b54d7468 | ||
|
|
2c9c79ea61 | ||
|
|
a2c6dbf479 | ||
|
|
b37471af68 | ||
|
|
60ade75031 | ||
|
|
7461bf4099 | ||
|
|
fc6d7db81b | ||
|
|
0901ed4659 | ||
|
|
a0685c69f5 | ||
|
|
87bf3c4e0d | ||
|
|
1f3bde3e08 | ||
|
|
6268a9f5b2 | ||
|
|
26dbb5ca3f | ||
|
|
ac14698ab9 | ||
|
|
eac5b19309 | ||
|
|
a9d2adea45 | ||
|
|
641d3f5e01 | ||
|
|
f7dc438d10 | ||
|
|
c987251585 | ||
|
|
dff626fb2d | ||
|
|
fce16c6cdd | ||
|
|
f84b3a509d | ||
|
|
22e30dc85a | ||
|
|
a1df7b9b26 | ||
|
|
8029d3e592 | ||
|
|
2eb3a5af3b | ||
|
|
22fa9831d8 | ||
|
|
b39ae8d23a | ||
|
|
6b30fc714c | ||
|
|
9d5c7ceecd | ||
|
|
e8dd5779f6 | ||
|
|
aa8ec4724b | ||
|
|
b946b3b2bc | ||
|
|
5fa34b10b3 | ||
|
|
caecca7e1f | ||
|
|
83cfb1c861 | ||
|
|
46fd23c452 | ||
|
|
7c61e00948 | ||
|
|
b0065f7a95 | ||
|
|
91961e629f | ||
|
|
e6b4dba330 | ||
|
|
d6c7bf5ac8 | ||
|
|
e97e73e66e | ||
|
|
b681cf2eaa | ||
|
|
65cc0954c8 | ||
|
|
9ead39e703 | ||
|
|
ac19ac8b83 | ||
|
|
c4f71df1b2 | ||
|
|
393e88e732 | ||
|
|
450b510d08 | ||
|
|
03f93063f8 | ||
|
|
eb83621fce | ||
|
|
b2cfce7243 | ||
|
|
2eeb80f173 | ||
|
|
086e786b28 | ||
|
|
23f0195619 | ||
|
|
a163f2da2d | ||
|
|
db2cbf33c3 | ||
|
|
52ec4ac1d8 | ||
|
|
4f9fa7f8ad | ||
|
|
aa20b94927 | ||
|
|
d2b5f429fe | ||
|
|
30e24296c4 | ||
|
|
e6c09f7413 | ||
|
|
4284a3f5dc | ||
|
|
2075de3d81 | ||
|
|
df3ee6005a | ||
|
|
14023a15e6 | ||
|
|
e47ac96587 | ||
|
|
bed30a5307 | ||
|
|
76f63ee262 | ||
|
|
4096a67251 | ||
|
|
382c1de981 | ||
|
|
7870e9a5e2 | ||
|
|
99bc7a997a | ||
|
|
65d32c7425 | ||
|
|
6c6ae9cb1a | ||
|
|
e6aabb9706 | ||
|
|
3d57c80656 | ||
|
|
c248d5455e | ||
|
|
0e153183d4 | ||
|
|
3c08a5ee6e | ||
|
|
f97b7c9e61 | ||
|
|
1b3a45aba9 | ||
|
|
6873504cc0 | ||
|
|
ffde7e183e | ||
|
|
ed0164843a | ||
|
|
f351ab9544 | ||
|
|
a99484ebc8 | ||
|
|
6a239bf18a | ||
|
|
f82b63483a | ||
|
|
f6811e858a | ||
|
|
c1b428489f | ||
|
|
5eb40be474 | ||
|
|
4845c1290c | ||
|
|
e0468f8b8e | ||
|
|
0c56fde5a9 | ||
|
|
fed36d2cd0 | ||
|
|
613c0122c0 | ||
|
|
bb0ace3a61 | ||
|
|
c659be7e17 | ||
|
|
6a7e28cc85 | ||
|
|
c1b6d03d1b | ||
|
|
37ec18b363 | ||
|
|
3658c57912 | ||
|
|
1489af0eca | ||
|
|
445aaeb700 | ||
|
|
d33af6e83e | ||
|
|
acd51268fc | ||
|
|
22c72060cf | ||
|
|
be937a795a | ||
|
|
b54c58235f | ||
|
|
8f99ebf27e | ||
|
|
f44acc9b0e | ||
|
|
44ce756cba | ||
|
|
cbb390a918 | ||
|
|
fac194f66c | ||
|
|
6631ebfdfa | ||
|
|
dc2ed19105 | ||
|
|
40b2acb472 | ||
|
|
2f622053a6 | ||
|
|
3efb1e4ac9 | ||
|
|
43cc3624ee | ||
|
|
4edf53899d | ||
|
|
e277decd4c | ||
|
|
a3906242e9 | ||
|
|
7dba1b5303 | ||
|
|
f50a6fc24c | ||
|
|
f4562fa352 | ||
|
|
1fda362ca3 | ||
|
|
35f0270688 | ||
|
|
4d12c69d68 | ||
|
|
c532a28a98 | ||
|
|
34b6627f9a | ||
|
|
d3a6190044 | ||
|
|
0bf45653e3 | ||
|
|
87961c1c53 | ||
|
|
c861622748 | ||
|
|
eb630d61db | ||
|
|
7fcdb9b975 | ||
|
|
bfa8131f4b | ||
|
|
27850ef5df | ||
|
|
c4a4aceeeb | ||
|
|
33b7585e0c | ||
|
|
ef370034b6 | ||
|
|
eb4bb6925f | ||
|
|
dea0fcc845 | ||
|
|
2cbfc60679 | ||
|
|
aec25c88b4 | ||
|
|
703266312e | ||
|
|
c90c32eee8 | ||
|
|
c3402dca4e | ||
|
|
590b6ba6e7 | ||
|
|
d2417768ce | ||
|
|
b6fd282143 | ||
|
|
afeb2cfc09 | ||
|
|
b6c710585b | ||
|
|
9af3ff6d9d | ||
|
|
b3479a4ab6 | ||
|
|
2b23eec0f7 | ||
|
|
8363757f1c | ||
|
|
a600d67dfc | ||
|
|
bf2b06880e | ||
|
|
9a63f34129 | ||
|
|
0ca836d7ed | ||
|
|
a85b47805f | ||
|
|
3440c54ab7 | ||
|
|
4b2d10a741 | ||
|
|
c231a349c7 | ||
|
|
f3ff8ca9ca | ||
|
|
1bfde8a1e5 | ||
|
|
67135a7150 | ||
|
|
18bcf3ea00 | ||
|
|
336d0a3972 | ||
|
|
045e0c70cb | ||
|
|
370355b94b | ||
|
|
7530109ce8 | ||
|
|
33e983a5c3 | ||
|
|
d7e46b5427 | ||
|
|
b86e9a4fc1 | ||
|
|
785e5e0fe7 | ||
|
|
d1e4387997 | ||
|
|
237778a8bc | ||
|
|
d8d92e3ff7 | ||
|
|
8b7a406fe7 | ||
|
|
940b2998ea | ||
|
|
aeae7c2c02 | ||
|
|
b346f6e8ad | ||
|
|
90739c9df9 | ||
|
|
4be9519e76 | ||
|
|
5550c89a86 | ||
|
|
820fd55249 | ||
|
|
4d81953562 | ||
|
|
56c5d28ede | ||
|
|
65a74f68d5 | ||
|
|
90392ec303 | ||
|
|
9cfefb64dd | ||
|
|
d4e9f26983 | ||
|
|
5316762a64 | ||
|
|
7a7ede22ea | ||
|
|
d719a09a58 | ||
|
|
8d652ff34d | ||
|
|
0fcc7d2b23 | ||
|
|
a34742040c | ||
|
|
7f0c334391 | ||
|
|
694f9ec8e2 | ||
|
|
db5060b323 | ||
|
|
f10c51b5f3 | ||
|
|
e971a01acd | ||
|
|
0cd0d1ea97 | ||
|
|
84b6e499b3 | ||
|
|
169e7e9623 | ||
|
|
ad1227d655 | ||
|
|
636071a22a | ||
|
|
20fd4ecb9a | ||
|
|
b33ae47a4c | ||
|
|
0ebab8f612 | ||
|
|
1eef88e85f | ||
|
|
7b7e348837 | ||
|
|
34977b836a | ||
|
|
cda1374b49 | ||
|
|
2c37fb639e | ||
|
|
a01de8c90e | ||
|
|
a6f975d79f | ||
|
|
457bd2339b | ||
|
|
98a4f2fedc | ||
|
|
5e79a8080b | ||
|
|
3f56b7e131 | ||
|
|
b7b91f27db | ||
|
|
ad15a14f5d | ||
|
|
2f876fb225 | ||
|
|
2b9c0e637f | ||
|
|
580adf8820 | ||
|
|
892573e53e | ||
|
|
fe600b7877 | ||
|
|
c4a0b41b8e | ||
|
|
d9e3c02df3 | ||
|
|
8c2b6b5ca1 | ||
|
|
6f21f5f03e | ||
|
|
1e4c401257 | ||
|
|
8f8fdcdc82 | ||
|
|
1d5a03a624 | ||
|
|
7a6a394bbf | ||
|
|
c0721241e5 | ||
|
|
8ce4635bd1 | ||
|
|
aa44582575 | ||
|
|
49e9ae313c | ||
|
|
ba7e252103 | ||
|
|
7ef0dec185 | ||
|
|
13dac91fa6 | ||
|
|
0269e89148 | ||
|
|
ef40b94a87 | ||
|
|
832e9a631e | ||
|
|
3a1bc715b7 | ||
|
|
2f1b12a6f1 | ||
|
|
4221eef428 | ||
|
|
c800508f87 | ||
|
|
3abb185e16 | ||
|
|
d7dcee737b | ||
|
|
68a4928fb1 | ||
|
|
abea8a2ff4 | ||
|
|
c9892569c9 | ||
|
|
26fcb9395e | ||
|
|
5ec85be299 | ||
|
|
2017228503 | ||
|
|
689255dec0 | ||
|
|
cdb1677b59 | ||
|
|
cb35363e10 | ||
|
|
05b70825fa | ||
|
|
e5147235cc | ||
|
|
c77dbaa67b | ||
|
|
ac73c4db0f | ||
|
|
88923a8b18 | ||
|
|
9b4b76d364 | ||
|
|
91b611acb7 | ||
|
|
7836cb2f01 | ||
|
|
a31f9cd26a | ||
|
|
4a053ebda9 | ||
|
|
4355686bd6 | ||
|
|
61638e8b72 | ||
|
|
cf07939792 | ||
|
|
bc6def277e | ||
|
|
487c9e1e72 | ||
|
|
8bb2ba2181 | ||
|
|
9fd850bf36 | ||
|
|
768e4a011e | ||
|
|
f26ac070d5 | ||
|
|
d34ecd1c25 | ||
|
|
3381fff6bd | ||
|
|
8a14f46595 | ||
|
|
4b7f8ca39d | ||
|
|
3a8310c0cc | ||
|
|
d3320963c3 | ||
|
|
3d4392ce63 | ||
|
|
187da3a743 | ||
|
|
0f2a50c62f | ||
|
|
9792089b57 | ||
|
|
047ab10dde | ||
|
|
39ccd1b3a4 | ||
|
|
f066c63900 | ||
|
|
dd05d6878c | ||
|
|
73ed0cd5bf | ||
|
|
d35591c5f4 | ||
|
|
5f336621c0 | ||
|
|
bcb4766f95 | ||
|
|
26de87951a | ||
|
|
58f4ab4e67 | ||
|
|
cac1c0d415 | ||
|
|
2ea195a5d2 | ||
|
|
0f4de88b92 | ||
|
|
492ea478e7 | ||
|
|
ae847994fc | ||
|
|
801eabe598 | ||
|
|
ca373b5aa5 | ||
|
|
6c1a309c40 | ||
|
|
8e50f50a48 | ||
|
|
5008b25a2d | ||
|
|
90919a66d9 | ||
|
|
452b082c82 | ||
|
|
0b52b8c470 | ||
|
|
7fa7ef103f | ||
|
|
7a4d40a8fd | ||
|
|
aaf0ca2105 | ||
|
|
378d3798fd | ||
|
|
d61bad16bf | ||
|
|
992706c5c1 | ||
|
|
3c50fc63c1 | ||
|
|
b8fcfbd182 | ||
|
|
26c56d277e | ||
|
|
fa7fc3ca5d | ||
|
|
c78e1519af | ||
|
|
91764cacd9 | ||
|
|
66024e5059 | ||
|
|
6f09c0ae18 | ||
|
|
f24cd4feed | ||
|
|
d5efe33944 | ||
|
|
7f788d6be1 | ||
|
|
8e6ccea085 | ||
|
|
4cc8b0bda5 | ||
|
|
e1c880cb22 | ||
|
|
7ba0b03e6c | ||
|
|
b1cdf48ca0 | ||
|
|
a330f4c130 | ||
|
|
644a3058de | ||
|
|
73dab5a398 | ||
|
|
23b851fab4 | ||
|
|
a6b4d539a6 | ||
|
|
73139a76a8 | ||
|
|
bf1a6f5899 | ||
|
|
718ff6b326 | ||
|
|
16be76a038 | ||
|
|
3d1743d91d | ||
|
|
a695da88df | ||
|
|
c75c123d37 | ||
|
|
a8b6aeeb05 | ||
|
|
b5ee05a13e | ||
|
|
b11320a5fb | ||
|
|
3701a82de1 | ||
|
|
ebc0ffd879 | ||
|
|
066d515547 | ||
|
|
bbf8420d80 | ||
|
|
5d1e0d5c44 | ||
|
|
bae530b30d | ||
|
|
84e81a9e4e | ||
|
|
82518fbbf4 | ||
|
|
512c4629b6 | ||
|
|
4292c1f207 | ||
|
|
84c7149f0f | ||
|
|
2317114b04 | ||
|
|
284dbff2d5 | ||
|
|
713a03ad89 | ||
|
|
57e2a8a0c9 | ||
|
|
a50ed46950 | ||
|
|
a3cdb667ba | ||
|
|
a70c32da3c | ||
|
|
45d67176c5 | ||
|
|
c0c92a82e2 | ||
|
|
ba8d429a9f | ||
|
|
b35bdc606a | ||
|
|
458838e9c6 | ||
|
|
d91f414313 | ||
|
|
eae52a8faf | ||
|
|
1585a91aec | ||
|
|
bacff3de8d | ||
|
|
5eaf3d40ad | ||
|
|
a7b79fc8b2 | ||
|
|
dbf2f6223c | ||
|
|
66a380dd8e | ||
|
|
a557cf388b | ||
|
|
17af1a68e8 | ||
|
|
e56fccaefe | ||
|
|
e9c0cb74d8 | ||
|
|
a3f2f7c039 | ||
|
|
c8111b988e | ||
|
|
1e5e06fef5 | ||
|
|
90d55ef901 | ||
|
|
9d1e881f12 | ||
|
|
da68e4ab11 | ||
|
|
1aa98bea2b | ||
|
|
038bfde6aa | ||
|
|
fb63198688 | ||
|
|
3b43fe7431 | ||
|
|
15c3e2f516 | ||
|
|
21cf7ceb07 | ||
|
|
321e821ae8 | ||
|
|
c8c3b825e4 | ||
|
|
fafea688e4 | ||
|
|
b1b0b2bc65 | ||
|
|
a6b7f47d74 | ||
|
|
1be50d83dc | ||
|
|
938200478f | ||
|
|
81245495ae | ||
|
|
26c8d81dbf | ||
|
|
ab4ed2e032 | ||
|
|
cb9fe8d9ff | ||
|
|
bd561dea6e | ||
|
|
88523e6ffb | ||
|
|
4a25b59d33 | ||
|
|
453b5a3e8c | ||
|
|
996322a2d3 | ||
|
|
9efc357226 | ||
|
|
5c48c87af0 | ||
|
|
ed46a93848 | ||
|
|
3c31758826 | ||
|
|
bc8aa82a13 | ||
|
|
c1b62bf672 | ||
|
|
c445563ab1 | ||
|
|
b30bdbfc6a | ||
|
|
0583d63b67 | ||
|
|
afd99a0c6c | ||
|
|
678be46bb9 | ||
|
|
197611fe8e | ||
|
|
1e7e9a9e02 | ||
|
|
e74806ab70 | ||
|
|
10d74bb37e | ||
|
|
6d125a8dfb | ||
|
|
bbf3bbcd4b | ||
|
|
43f89209d3 | ||
|
|
ecd09f22ef | ||
|
|
1c55878271 | ||
|
|
f110dc970d | ||
|
|
96edd759a8 | ||
|
|
f1843a57e0 | ||
|
|
ef0eb8be02 | ||
|
|
b332a9ed41 | ||
|
|
b011d14653 | ||
|
|
b50216600b | ||
|
|
5ccb7a5856 | ||
|
|
124d50e6d6 | ||
|
|
c91b2cc795 | ||
|
|
a53265b76e | ||
|
|
ac8a2d03d8 | ||
|
|
3e01ce2a6c | ||
|
|
24b575d21a | ||
|
|
5367ac562c | ||
|
|
d503ee94f5 | ||
|
|
63114b988f | ||
|
|
69630a53f7 | ||
|
|
ce7e6d37ed | ||
|
|
ab7536ffd7 | ||
|
|
2ce090e4b3 | ||
|
|
986b843e0d | ||
|
|
26837ae8d3 | ||
|
|
1cdd5db29f | ||
|
|
cf7a1392ac | ||
|
|
b0fdd45205 | ||
|
|
57d5022e3d | ||
|
|
a7f9a5c4ad | ||
|
|
8b1d3c7658 | ||
|
|
95f0be6247 | ||
|
|
99ccfc241c | ||
|
|
a8aa5b1447 | ||
|
|
e5f0e57980 | ||
|
|
531b702d65 | ||
|
|
41ec85053e | ||
|
|
71ac550e7d | ||
|
|
852305b996 | ||
|
|
381d160e3b | ||
|
|
eb0584d466 | ||
|
|
76cf0e445d | ||
|
|
1d63e5a2df | ||
|
|
f9a8575414 | ||
|
|
3939d4e2f0 | ||
|
|
451cde7918 | ||
|
|
69ae1d3534 | ||
|
|
de243855c4 | ||
|
|
9c71970d69 | ||
|
|
4f5ad3c7b6 | ||
|
|
2d1b934a1c | ||
|
|
9392f9b512 | ||
|
|
397336e03c | ||
|
|
61148d8215 | ||
|
|
4bed8647ac | ||
|
|
022f6608b9 | ||
|
|
827fd4d070 | ||
|
|
2b444b0a28 | ||
|
|
f52412df41 | ||
|
|
0e9dc61805 | ||
|
|
6132ed1967 | ||
|
|
5b1c372fdf | ||
|
|
c97ab456f2 | ||
|
|
0466ae5e07 | ||
|
|
26987148b5 | ||
|
|
5606d4bb12 | ||
|
|
5baf16ad6e | ||
|
|
a8e7903f39 | ||
|
|
33122701b9 | ||
|
|
f517445d36 | ||
|
|
9d41958b3a | ||
|
|
7087c20c4f | ||
|
|
cb54fb5a64 | ||
|
|
e2b08a1758 | ||
|
|
c43e014304 | ||
|
|
9c7f1d94c0 | ||
|
|
a86852fe90 | ||
|
|
80f0c42844 | ||
|
|
0a78b02bf1 | ||
|
|
dba9f8854f | ||
|
|
8431fd822f | ||
|
|
be0f11b1a5 | ||
|
|
721dc6dae4 | ||
|
|
39dee9d17c | ||
|
|
1d7d0ea2d4 | ||
|
|
1c47ade641 | ||
|
|
52895658e5 | ||
|
|
c3645e463e | ||
|
|
4576a1d245 | ||
|
|
313eb71b17 | ||
|
|
8f51741c65 | ||
|
|
fc560d179e | ||
|
|
8d6bbb8c1a | ||
|
|
e0d697d0bc | ||
|
|
f3f2240e4a | ||
|
|
e3e64a3a18 | ||
|
|
6ef60aa979 | ||
|
|
00664ac601 | ||
|
|
a4e0a7f235 | ||
|
|
ae0cf49560 | ||
|
|
cd2b9ce975 | ||
|
|
3f8fd8aa09 | ||
|
|
8b8cb28259 | ||
|
|
ef138bb132 | ||
|
|
fdb46d80ba | ||
|
|
6b0e29ad55 | ||
|
|
8248dfa004 | ||
|
|
80f2ac5c3c | ||
|
|
7eaa8a3f16 | ||
|
|
1776430f6b | ||
|
|
584d3ea272 | ||
|
|
52f5c45ca8 | ||
|
|
c523a0b509 | ||
|
|
4eeaa16f16 | ||
|
|
e630476f9f | ||
|
|
d7ab76106d | ||
|
|
44045a02f2 | ||
|
|
6b42227b13 | ||
|
|
61e1f56922 | ||
|
|
2539c93783 | ||
|
|
8f5a9859c3 | ||
|
|
21e2898868 | ||
|
|
69cc3c8261 | ||
|
|
df18acbabb | ||
|
|
c719e7dc31 | ||
|
|
0a28f3f648 | ||
|
|
67ca009690 | ||
|
|
ec0dd39220 | ||
|
|
379171e302 | ||
|
|
24499d7bed | ||
|
|
2ab1ce7b83 | ||
|
|
76fd70657a | ||
|
|
f103d5964e | ||
|
|
7b0765e064 | ||
|
|
07b5f3d597 | ||
|
|
0e9685e55f | ||
|
|
b6c88b1a6c | ||
|
|
9f042db0f5 | ||
|
|
03993cd5fa | ||
|
|
079ec43291 | ||
|
|
b55e8fc4c4 | ||
|
|
f72cedf446 | ||
|
|
5266e10140 | ||
|
|
503a2adc38 | ||
|
|
dfc50562a1 | ||
|
|
5452221cd2 | ||
|
|
972743cb63 | ||
|
|
916c30072b | ||
|
|
30f78f7fa6 | ||
|
|
ca8be5015a | ||
|
|
e07fbbbcd8 | ||
|
|
fd6de36d2c | ||
|
|
70f1ec9dce | ||
|
|
4001a2d316 | ||
|
|
20bdc82cba | ||
|
|
2196f77081 | ||
|
|
09f1983d40 | ||
|
|
a752bc012c | ||
|
|
29e376a66e | ||
|
|
2cad421de9 | ||
|
|
17cc9a43bb | ||
|
|
cf5278b7e9 | ||
|
|
fd67e47128 | ||
|
|
ef470de3ca | ||
|
|
5105c5f200 | ||
|
|
040dd3c409 | ||
|
|
10a5db7924 | ||
|
|
3720333927 | ||
|
|
54904a1630 | ||
|
|
e0ecb64a10 | ||
|
|
847d049f1c | ||
|
|
234669f469 | ||
|
|
7a529aee63 | ||
|
|
14d0c1325c | ||
|
|
b76b002966 | ||
|
|
8b64e905b8 | ||
|
|
5ca1a80ad5 | ||
|
|
94e004a828 | ||
|
|
69799bd11e | ||
|
|
4a5c32e375 | ||
|
|
362e176397 | ||
|
|
7593ecb870 | ||
|
|
cb7b0d8a4f | ||
|
|
ad016de653 | ||
|
|
5e6a502167 | ||
|
|
3cff05ef91 | ||
|
|
bc3af134f9 | ||
|
|
3650a2fa85 | ||
|
|
c2db17df9a | ||
|
|
45f2f07b6d | ||
|
|
be3be0478b | ||
|
|
97bb5bcb7d | ||
|
|
8255164eda | ||
|
|
63c5ebf428 | ||
|
|
a9006f540f | ||
|
|
04a98f99b7 | ||
|
|
a95aad324f | ||
|
|
f130ad6c27 | ||
|
|
f77b3dbd0a | ||
|
|
31a22d4c6a | ||
|
|
4d0265cb7d | ||
|
|
07f62fda32 | ||
|
|
24bb89df23 | ||
|
|
424f05a4da | ||
|
|
41b02928ef | ||
|
|
46819acaff | ||
|
|
44cd5ca15e | ||
|
|
25a900c759 | ||
|
|
968de0557a | ||
|
|
8f36cf3c81 | ||
|
|
b855f422ef | ||
|
|
bbdb0320f1 | ||
|
|
1dd26fcd73 | ||
|
|
dc4ff25d5b | ||
|
|
bd3b93f290 | ||
|
|
d779662bdd | ||
|
|
2b6edd153b | ||
|
|
ff3dacedc0 | ||
|
|
8fcf814eb6 | ||
|
|
2b4c75543a | ||
|
|
d566a328a3 | ||
|
|
99ea0dc59d | ||
|
|
4d91c4a51b | ||
|
|
3b0c685679 | ||
|
|
0e2cf6532b | ||
|
|
4d6555441d | ||
|
|
5ce95f6b88 | ||
|
|
23902a62d7 | ||
|
|
be9a1d6b27 | ||
|
|
0032e4b6cf | ||
|
|
9a2e6dcba5 | ||
|
|
da4cf61a09 | ||
|
|
6b2dd69bcb | ||
|
|
fb6b514c34 | ||
|
|
b20424261c | ||
|
|
caed69d5ea | ||
|
|
0334074a52 | ||
|
|
a3d6972268 | ||
|
|
c76644323f | ||
|
|
83aea10f06 | ||
|
|
0e9a8a7cc2 | ||
|
|
510064d9c8 | ||
|
|
4e3ccfffbb | ||
|
|
f6d75f2db2 | ||
|
|
dfbeddf1f7 | ||
|
|
2dd96c584b | ||
|
|
c75e145151 | ||
|
|
a478ec6697 | ||
|
|
992a926592 | ||
|
|
439b7be77d | ||
|
|
5eabc15a7d | ||
|
|
4f222e64e6 | ||
|
|
df9e8f5214 | ||
|
|
20e07a8387 | ||
|
|
e63d0d7aac | ||
|
|
e65ad67b32 | ||
|
|
56184daf59 | ||
|
|
100a75908b | ||
|
|
ba13f78d49 | ||
|
|
89527d3bb2 | ||
|
|
8151de1ae0 | ||
|
|
7d997f7699 | ||
|
|
cae2a11f7b | ||
|
|
b1f01506ce | ||
|
|
4d47d313f9 | ||
|
|
0e9d826d41 | ||
|
|
c72a735851 | ||
|
|
c41d7b8f6d | ||
|
|
264bc15091 | ||
|
|
b6e880ed73 | ||
|
|
12d88c2c2a | ||
|
|
c14fa299e1 | ||
|
|
57f27cc97a | ||
|
|
80f1581d6e | ||
|
|
a86c53236d | ||
|
|
2576659923 | ||
|
|
bc54d9777d | ||
|
|
b50023ede1 | ||
|
|
9499a8297a | ||
|
|
24dbc1f3d9 | ||
|
|
5ec686b030 | ||
|
|
e87652e95f | ||
|
|
b0bf775da8 | ||
|
|
e43eee2eb1 | ||
|
|
57b3e8018b | ||
|
|
00bbc17e11 | ||
|
|
b02c11c31d | ||
|
|
30e7f09000 | ||
|
|
742479f8bd | ||
|
|
6455f1388a | ||
|
|
9fb634ed3a | ||
|
|
100081757c | ||
|
|
234bfe1199 | ||
|
|
a2f8fa7b05 | ||
|
|
a21673069b | ||
|
|
1c593c9c3c | ||
|
|
c067cddbe8 | ||
|
|
3e525fbe1b | ||
|
|
0b6d260fa6 | ||
|
|
007d0d9ce9 | ||
|
|
fda44cdbf7 | ||
|
|
242c143c85 | ||
|
|
522bbfb716 | ||
|
|
a959c48708 | ||
|
|
a9ce12be34 | ||
|
|
05239c26f9 | ||
|
|
fc07032d35 | ||
|
|
7e6af57186 | ||
|
|
5a0251c3cd | ||
|
|
c8c38e498a | ||
|
|
70bce24b0a | ||
|
|
4484baa866 | ||
|
|
c3fc19353b | ||
|
|
cd60f9a8e2 | ||
|
|
8d78651606 | ||
|
|
ea200dea40 | ||
|
|
ec557f8d44 | ||
|
|
49d7901585 | ||
|
|
cdd5d1196a | ||
|
|
06d85595a0 | ||
|
|
2863c2d593 | ||
|
|
58812b326c | ||
|
|
609064b9d8 | ||
|
|
64435c87df | ||
|
|
470096047b | ||
|
|
7a2aa43caa | ||
|
|
58fd07e4c6 | ||
|
|
04d16d7607 | ||
|
|
20f52a7fb1 | ||
|
|
e877ed01af | ||
|
|
a0f1c1d17a | ||
|
|
e872435870 | ||
|
|
bbfd97e2b8 | ||
|
|
a9324ba9d4 | ||
|
|
d3f0210b1a | ||
|
|
60fbc51a2d | ||
|
|
c8401a3c4d | ||
|
|
b633f83bd9 | ||
|
|
b477514e58 | ||
|
|
30c78b4054 | ||
|
|
1f453c9394 | ||
|
|
7a7f486cb2 | ||
|
|
9b643d57f0 | ||
|
|
569b15d790 | ||
|
|
8ff1bcf242 | ||
|
|
1245af356b | ||
|
|
b459a29947 | ||
|
|
83d83a09b5 | ||
|
|
71803658f5 | ||
|
|
a4af51af14 | ||
|
|
a2558972b9 | ||
|
|
b0a09f01cc | ||
|
|
18f27d2e45 | ||
|
|
19a43cea26 | ||
|
|
36feb7f50a | ||
|
|
5db6f81f9c | ||
|
|
730faed757 | ||
|
|
06d4f5b0db | ||
|
|
e891162dad | ||
|
|
0b8fea923a | ||
|
|
ef5deac3e1 | ||
|
|
1d33d7d124 | ||
|
|
a1c6d9d1da | ||
|
|
1b73e15ec7 | ||
|
|
0b751949ae | ||
|
|
d0f545c247 | ||
|
|
046efe3acb | ||
|
|
5f734f4dd9 | ||
|
|
948a5c97ec | ||
|
|
1dc002f180 | ||
|
|
6da0257bb6 | ||
|
|
633d0453be | ||
|
|
aec861c1a4 | ||
|
|
cdeceb140d | ||
|
|
ebd597b811 | ||
|
|
284500ff21 | ||
|
|
6242b856b4 | ||
|
|
020213e682 | ||
|
|
c7ae0f5f90 | ||
|
|
5b9b744662 | ||
|
|
06ad3987ba | ||
|
|
c46e27928f | ||
|
|
eaded9b67c | ||
|
|
90eb8aa82a | ||
|
|
e915dd0020 | ||
|
|
e32c2d2569 | ||
|
|
74d243ac41 | ||
|
|
06c3087310 | ||
|
|
aa0c6ef7c2 | ||
|
|
a7e4d5102e | ||
|
|
56622596e7 | ||
|
|
7ee37648d8 | ||
|
|
95852db18d | ||
|
|
532e9f5de2 | ||
|
|
399b433a06 | ||
|
|
7dc3198320 | ||
|
|
af6407c1df | ||
|
|
bfb5089ed5 | ||
|
|
38fbc3595a | ||
|
|
c85e9625a7 | ||
|
|
6dc18ba603 | ||
|
|
7772d5af62 | ||
|
|
a2f438c6ef | ||
|
|
1b29d61562 | ||
|
|
efdb54cbe4 | ||
|
|
7a21e8a3fb | ||
|
|
50ff26ea20 | ||
|
|
f38787c258 | ||
|
|
42dc973ccc | ||
|
|
dc8147c46d | ||
|
|
a30d1dcfef | ||
|
|
851bff1e12 | ||
|
|
e6ed2e58b0 | ||
|
|
0f1307cd81 | ||
|
|
bb858fdddc | ||
|
|
43f0014200 | ||
|
|
c3c07d621a | ||
|
|
7e3de4d6f7 | ||
|
|
7880b6a11d | ||
|
|
1215b5e994 | ||
|
|
828c78cb1f | ||
|
|
b42b680ef5 | ||
|
|
d7a4242c74 | ||
|
|
dd92173576 | ||
|
|
3173249dc3 | ||
|
|
97258747c7 | ||
|
|
19964e914a | ||
|
|
d3e107a882 | ||
|
|
250f35c2a5 | ||
|
|
fceac45ddd | ||
|
|
ba58c080e3 | ||
|
|
ff0099b6a7 | ||
|
|
5536f1621d | ||
|
|
fde0ce1997 | ||
|
|
3e15742875 | ||
|
|
68668fc658 | ||
|
|
72b930af8f | ||
|
|
1b4ff986b0 | ||
|
|
4e6773969a | ||
|
|
ba9f29a04b | ||
|
|
b04d6f94e6 | ||
|
|
a09c5d88db | ||
|
|
be9d6b9422 | ||
|
|
84844c242b | ||
|
|
fc3375508e | ||
|
|
663735542b | ||
|
|
a90dcabe01 | ||
|
|
fa9f5073f6 | ||
|
|
9616a2292e | ||
|
|
b4f743bda3 | ||
|
|
138eadedcc | ||
|
|
53de2ad86d | ||
|
|
8dfc91a502 | ||
|
|
3449b3d1d7 | ||
|
|
7b62cf4af8 | ||
|
|
2727fd12ee | ||
|
|
00047009e2 | ||
|
|
0fd89a4b1f | ||
|
|
7938cbffd4 | ||
|
|
5fe50066a6 | ||
|
|
3b7b34b3df | ||
|
|
508a433a92 | ||
|
|
fce3e239a7 | ||
|
|
89100d14c8 | ||
|
|
f0c6ac1aa3 | ||
|
|
63bf1373b7 | ||
|
|
19d243d159 | ||
|
|
67161d686b | ||
|
|
8bd803601f | ||
|
|
8567dd001b | ||
|
|
7636769c11 | ||
|
|
7c2d92c55d | ||
|
|
fa76cb8f8c | ||
|
|
1b00515899 | ||
|
|
e7b9b86c64 | ||
|
|
85f7f5589d | ||
|
|
9c61c281ca | ||
|
|
bb5e8e00dd | ||
|
|
b38146bdef | ||
|
|
80ffe74af6 | ||
|
|
004d4ed123 | ||
|
|
28059bda7f | ||
|
|
3ebfc0e917 | ||
|
|
1206c2113c | ||
|
|
461e0d0314 | ||
|
|
8da1fb1d74 | ||
|
|
98f3e6ed64 | ||
|
|
bbe19cbbb0 | ||
|
|
f2b602c7ec | ||
|
|
223d2c2c3f | ||
|
|
356732189c | ||
|
|
b2b82d955c | ||
|
|
f356723e20 | ||
|
|
4ef17270b5 | ||
|
|
b73fd6d522 | ||
|
|
6b5920b98b | ||
|
|
f6f76acdb0 | ||
|
|
ee20268e27 | ||
|
|
0b9cf6384e | ||
|
|
cda821e649 | ||
|
|
71ca07363a | ||
|
|
b73700d139 | ||
|
|
88ce2255f9 | ||
|
|
7951137693 | ||
|
|
015e7f8acf | ||
|
|
22a2b65e3f | ||
|
|
e9218e2eb2 | ||
|
|
a013ccf806 | ||
|
|
7f7a1f2740 | ||
|
|
3239c04368 | ||
|
|
b5a3a72b51 | ||
|
|
6011cee490 | ||
|
|
cfb7b8301c | ||
|
|
69463308f5 | ||
|
|
8819aa7079 | ||
|
|
c3155651e4 | ||
|
|
24d9856ae6 | ||
|
|
89f59a758d | ||
|
|
ff4c3f791c | ||
|
|
8b590a43be | ||
|
|
f46e0408b3 | ||
|
|
a7c6413d07 | ||
|
|
fd77e0e31d | ||
|
|
6119e1e660 | ||
|
|
d81723c8fc | ||
|
|
b6a3524e9b | ||
|
|
1d56181a8c | ||
|
|
8e29910e77 | ||
|
|
37a9dbf1d5 | ||
|
|
3ee2c6e210 | ||
|
|
c8cbb8ebb5 | ||
|
|
4047bf0775 | ||
|
|
faddb5d57e | ||
|
|
3be8a1ad02 | ||
|
|
dcffd102cc | ||
|
|
791ebff7ee | ||
|
|
7dd7c489e8 | ||
|
|
846e11d6b8 | ||
|
|
3f26fc3b06 | ||
|
|
7a8f6500e2 | ||
|
|
50bb4daeaa | ||
|
|
b643ef628b | ||
|
|
ce39a6fb18 | ||
|
|
f5084a5f70 | ||
|
|
896aa31fdc | ||
|
|
11b47063eb | ||
|
|
5ba752c331 | ||
|
|
6c4e044c92 | ||
|
|
1d5a3b73f9 | ||
|
|
8c20e5137e | ||
|
|
eea4bb7118 | ||
|
|
1bc1cbd664 | ||
|
|
f8fc8c888f | ||
|
|
09bf64db42 | ||
|
|
33daf0a385 | ||
|
|
152ec80bfe | ||
|
|
371b3775fa | ||
|
|
2cfcbf6380 | ||
|
|
5f0b3d0fca | ||
|
|
06fac90ec2 | ||
|
|
d053f93419 | ||
|
|
e3643b1faf | ||
|
|
029379c092 | ||
|
|
36544ee088 | ||
|
|
45dd8cbc3f | ||
|
|
772df97bc1 | ||
|
|
0c181ead59 | ||
|
|
76d14157ec | ||
|
|
ce7b8b5e08 | ||
|
|
cb9ad467af | ||
|
|
0ed8158f6e | ||
|
|
22c75f853b | ||
|
|
b31668fba9 | ||
|
|
999e68812d | ||
|
|
115be859b6 | ||
|
|
2e279d4722 | ||
|
|
39485e7583 | ||
|
|
ba7e06072d | ||
|
|
7e9a254d87 | ||
|
|
fbae2ef725 | ||
|
|
3439f4bb93 | ||
|
|
e2f51ef557 | ||
|
|
807ceadf8b | ||
|
|
3c95d80d3e | ||
|
|
10bbc3d6e1 | ||
|
|
1ce8b6de7a | ||
|
|
c75447bc66 | ||
|
|
3709840327 | ||
|
|
cddeddac8d | ||
|
|
6878fc254a | ||
|
|
13ac71bdf0 | ||
|
|
61f6aff056 | ||
|
|
8feeafd8a3 | ||
|
|
6f3ef12d31 | ||
|
|
7c45318c00 | ||
|
|
c2cd181c1a | ||
|
|
7066f25423 | ||
|
|
631251f1f7 | ||
|
|
ade833a70a | ||
|
|
253e3eb628 | ||
|
|
6588ca6520 | ||
|
|
4d0bd03569 | ||
|
|
ed1c98e590 | ||
|
|
8b947e2fab | ||
|
|
ead5d99394 | ||
|
|
98a5542cee | ||
|
|
4f910beba4 | ||
|
|
5b10d35aa8 | ||
|
|
99c87ff862 | ||
|
|
dbefeb3f6b | ||
|
|
ff230cefe3 | ||
|
|
d3f1b83e57 | ||
|
|
46834aa0a5 | ||
|
|
2016da984a | ||
|
|
cdbcc844cf | ||
|
|
980ecdaacb | ||
|
|
0c5f1234da | ||
|
|
35094a1667 | ||
|
|
8d0bddac6c | ||
|
|
9d933f517b | ||
|
|
c224b91945 | ||
|
|
abfaca3bfb | ||
|
|
a287bb5da0 | ||
|
|
a7315684c4 | ||
|
|
15012688f2 | ||
|
|
78f7c3cbea | ||
|
|
80c2d81f41 | ||
|
|
454bea4237 | ||
|
|
7fbd36ccad | ||
|
|
ce9de1b7d2 | ||
|
|
869c4eb68d | ||
|
|
dd38621352 | ||
|
|
5f0f06b22d | ||
|
|
ed05ff6fd9 | ||
|
|
50eecd11c1 | ||
|
|
1f32ce2ca4 | ||
|
|
45e295c1d3 | ||
|
|
8562b8f09d | ||
|
|
9ffe35756b | ||
|
|
93f93aff93 | ||
|
|
db48b2ba34 | ||
|
|
702498ca09 | ||
|
|
c116cb095d | ||
|
|
ced3d595cc | ||
|
|
8c62ae4ce5 | ||
|
|
3e4c0261b1 | ||
|
|
fd6ff4c8d3 | ||
|
|
7f7c403e00 | ||
|
|
aa9673b208 | ||
|
|
2fa4e2e468 | ||
|
|
d1ad6f3e65 | ||
|
|
b9a08bb25d | ||
|
|
646f22bfbb | ||
|
|
86e4fb9655 | ||
|
|
19c474ba2f | ||
|
|
8665f08dca | ||
|
|
aea6042fe1 | ||
|
|
0f8e282386 | ||
|
|
9db1f3f8b7 | ||
|
|
cac1f56b2d | ||
|
|
ca49a2aa68 | ||
|
|
283b187501 | ||
|
|
035e3e686e | ||
|
|
6cd53f2ddf | ||
|
|
249cf244ca | ||
|
|
cd896627ea | ||
|
|
dccd9f562f | ||
|
|
c2b8f8d34e | ||
|
|
9db1a58cb1 | ||
|
|
ebc67f460e | ||
|
|
e01fee9189 | ||
|
|
ba179bc638 | ||
|
|
035d994705 | ||
|
|
f1209a42a9 | ||
|
|
d4cad0b267 | ||
|
|
e0b424c88f | ||
|
|
f5683797aa | ||
|
|
efe820628c | ||
|
|
300cfcc424 | ||
|
|
2c5886f6d4 | ||
|
|
fc33273464 | ||
|
|
cbbda2db55 | ||
|
|
6943a3bc14 | ||
|
|
8f3a3f89a7 | ||
|
|
68b712adfd | ||
|
|
24be24c58b | ||
|
|
db7004fdee | ||
|
|
b10b75b7fe | ||
|
|
ed3bbd98cc | ||
|
|
ed1f434a61 | ||
|
|
b90826c267 | ||
|
|
1b0143341c | ||
|
|
b1a93ffc21 | ||
|
|
b63784a4e6 | ||
|
|
3894dec274 | ||
|
|
635876c94d | ||
|
|
490543093d | ||
|
|
4fec2dcb28 | ||
|
|
a6ec071244 | ||
|
|
f64e84d087 | ||
|
|
1ebaf7fd36 | ||
|
|
67d62a1723 | ||
|
|
edb01b6bb4 | ||
|
|
5e9303dbf2 | ||
|
|
a0a1573dc9 | ||
|
|
debca88a0d | ||
|
|
dec12be52e | ||
|
|
85f5df55e9 | ||
|
|
fee51d604d | ||
|
|
d506d0f424 | ||
|
|
4dcaf12fa7 | ||
|
|
cfeb1f1538 | ||
|
|
973525da6d | ||
|
|
d5737aafce | ||
|
|
b0b62d5db0 | ||
|
|
03e30ea5ed | ||
|
|
99b1cbf9b5 | ||
|
|
b1cc760bd1 | ||
|
|
83320681f0 | ||
|
|
c436b33da9 | ||
|
|
89a548252a | ||
|
|
df3ddea23c | ||
|
|
029c38874b | ||
|
|
5f7a9d9918 | ||
|
|
da4ab542a7 | ||
|
|
0cfae7245b | ||
|
|
249d4d7062 | ||
|
|
e3961b7532 | ||
|
|
47e6290609 | ||
|
|
47adae7917 | ||
|
|
970014588a | ||
|
|
528cd8ee48 | ||
|
|
7a9898fbd2 | ||
|
|
c18bb7dcad | ||
|
|
bacad3b60e | ||
|
|
1c1d075c12 | ||
|
|
69a616a0ba | ||
|
|
a49c839514 | ||
|
|
18396d2ee5 | ||
|
|
cec5ca8ba2 | ||
|
|
4495812b84 | ||
|
|
a54e6af24a | ||
|
|
bea7634b47 | ||
|
|
344ce6ee4d | ||
|
|
4e4e6b1133 | ||
|
|
5e8673fc4a | ||
|
|
b091e9c31c | ||
|
|
dfa1e1c586 | ||
|
|
78d5625ace | ||
|
|
f8223053bd | ||
|
|
e2b434b24e | ||
|
|
7c404a0551 | ||
|
|
81be3811dc | ||
|
|
40cde1f836 | ||
|
|
36cb12cd15 | ||
|
|
df3521e706 | ||
|
|
ea1e4108cc | ||
|
|
756425f7b4 | ||
|
|
00e1ecb5ad | ||
|
|
4e1b094449 | ||
|
|
dd55d6c7f9 | ||
|
|
6044742cee | ||
|
|
99447eaa17 | ||
|
|
e7dff308ef | ||
|
|
1f582cbeec | ||
|
|
68aa78d1fe | ||
|
|
0527760e9b | ||
|
|
513a03fb46 | ||
|
|
48089b01ab | ||
|
|
31b9f65513 | ||
|
|
c92089808f | ||
|
|
eef4817804 | ||
|
|
12c734fa48 | ||
|
|
ed150b8ea5 | ||
|
|
5835d502c7 | ||
|
|
c08676aa81 | ||
|
|
6f05548ec8 | ||
|
|
a4eb975b59 | ||
|
|
014abdba39 | ||
|
|
cdccdb432a | ||
|
|
63f8f2ee7f | ||
|
|
cfae4c667a | ||
|
|
006310c883 | ||
|
|
89102b5652 | ||
|
|
5fe73cf33e | ||
|
|
09908f5780 | ||
|
|
1c94bb1c0f | ||
|
|
ce1a5de607 | ||
|
|
c3047efc45 | ||
|
|
9c4111403e | ||
|
|
cda04b7ece | ||
|
|
4405d09d38 | ||
|
|
2220df5a3e | ||
|
|
5f9787aeb2 | ||
|
|
845a028d42 | ||
|
|
3e348880d5 | ||
|
|
100948eb38 | ||
|
|
5cbe7bf1b8 | ||
|
|
8c56b415cb | ||
|
|
4e155d50f3 | ||
|
|
470125b69a | ||
|
|
fa3b63f5e5 | ||
|
|
244e2a0e7e | ||
|
|
fa1b5b846e | ||
|
|
c6cb2c27bd | ||
|
|
a11ef38c9b | ||
|
|
bc4b81d525 | ||
|
|
ad16c32504 | ||
|
|
5278fe2f47 | ||
|
|
ec59c3c793 | ||
|
|
e5be72e445 | ||
|
|
296a5e3b10 | ||
|
|
38b85e3ca2 | ||
|
|
b94ab32d60 | ||
|
|
5943f757a0 | ||
|
|
1069d79298 | ||
|
|
2866437a1f | ||
|
|
a391bc3d3f | ||
|
|
feeeac2a75 | ||
|
|
01f738c151 | ||
|
|
4b2fa2d413 | ||
|
|
d3bf245331 | ||
|
|
4fb2fcc7a0 | ||
|
|
66f8daded1 | ||
|
|
cc4c557e89 | ||
|
|
9656ff6636 | ||
|
|
88c4c77cbd | ||
|
|
651d2dfd86 | ||
|
|
67bb64ab6b | ||
|
|
04e58bd375 | ||
|
|
a4dab870ce | ||
|
|
aab52ca686 | ||
|
|
5d107ed74b | ||
|
|
973ce21353 | ||
|
|
0c7b6e26aa | ||
|
|
20ff5fadee | ||
|
|
bdfb8deb94 | ||
|
|
fa75458b30 | ||
|
|
cea18ee561 | ||
|
|
2478656622 | ||
|
|
23d080af86 | ||
|
|
4dcdca1bd5 | ||
|
|
2051871c81 | ||
|
|
da960b29da | ||
|
|
9f9b926011 | ||
|
|
8c6e6e464e | ||
|
|
f4e54719b9 | ||
|
|
c856c117a8 | ||
|
|
8ae9faf128 | ||
|
|
4f226bcc7a | ||
|
|
15f67fd6a7 | ||
|
|
47dea785a8 | ||
|
|
ca336bef57 | ||
|
|
a4bac63161 | ||
|
|
2ec1f20a03 | ||
|
|
5770cc03a1 | ||
|
|
222d57bda7 | ||
|
|
1e136a2416 | ||
|
|
c3c1383ae6 | ||
|
|
9f24101348 | ||
|
|
aa80841519 | ||
|
|
4c5bad495f | ||
|
|
1d90148f62 | ||
|
|
a9ee2f9c54 | ||
|
|
3c37f491b2 | ||
|
|
ac2389a0a5 | ||
|
|
714f747b61 | ||
|
|
904bf4493e | ||
|
|
f9fbb30fc0 | ||
|
|
c489c68f02 | ||
|
|
bb23f57f96 | ||
|
|
98c2f1ea42 | ||
|
|
94d9cbf76e | ||
|
|
d56edd46bb | ||
|
|
542e6b9536 | ||
|
|
4f0b828a15 | ||
|
|
1bab576be7 | ||
|
|
f9462613f5 | ||
|
|
ac8d70d547 | ||
|
|
a4dd58cf5e | ||
|
|
a0ab73882c | ||
|
|
48c3c44aba | ||
|
|
523cd8249f | ||
|
|
b686f04121 | ||
|
|
990ac057db | ||
|
|
d1f3c84212 | ||
|
|
86dc0a973c | ||
|
|
ece0902ab2 | ||
|
|
895ba6adbc | ||
|
|
7db8bc6423 | ||
|
|
68f8fd290a | ||
|
|
22b3d7810d | ||
|
|
82b2b9cb94 | ||
|
|
1d0657ff54 | ||
|
|
30ba447c64 | ||
|
|
3bab3f4be1 | ||
|
|
b9d0316903 | ||
|
|
2e721f92a3 | ||
|
|
d2a4d67cb0 | ||
|
|
506496743d | ||
|
|
3db43d6545 | ||
|
|
b4de063e76 | ||
|
|
e7c648a2c3 | ||
|
|
5596ac7d55 | ||
|
|
450ce69353 | ||
|
|
3eca37afd2 | ||
|
|
2a15e239c3 | ||
|
|
552f78fc5c | ||
|
|
5d641b76d2 | ||
|
|
55f85f59d9 | ||
|
|
7eafa5805a | ||
|
|
f510ee333b | ||
|
|
dc157edd7d | ||
|
|
7cdda3a3d7 | ||
|
|
e8ab546d32 | ||
|
|
80ecbe8057 | ||
|
|
8a8097af99 | ||
|
|
a0c12fe685 | ||
|
|
e71efb3b68 | ||
|
|
c69b9aefec | ||
|
|
58c90402c5 | ||
|
|
7c17987585 | ||
|
|
9979a3266e | ||
|
|
001f27cdb4 | ||
|
|
951c3683b2 | ||
|
|
a339fd0b53 | ||
|
|
8db1b74a3c | ||
|
|
8ef3009cc7 | ||
|
|
05e6ac8c1f | ||
|
|
f06e4bde94 | ||
|
|
1ce48ece10 | ||
|
|
6164895bc9 | ||
|
|
8abe473e53 | ||
|
|
a5976b7c6f | ||
|
|
aa33e051bb | ||
|
|
e8a5fa1413 | ||
|
|
38ed025ce3 | ||
|
|
fbc1c21b2a | ||
|
|
27d448870d | ||
|
|
29c62780fc | ||
|
|
31c88efc4a | ||
|
|
17eefcffbe | ||
|
|
d570aeef33 | ||
|
|
f24e9597fe | ||
|
|
997c2e8ef6 | ||
|
|
cb33b3bf24 | ||
|
|
2226f8b6a9 | ||
|
|
c84cb86c87 | ||
|
|
00c206c37b | ||
|
|
16453a7728 | ||
|
|
8b02c3c1cc | ||
|
|
2eebe7d91e | ||
|
|
00f0890021 | ||
|
|
6e7887db23 | ||
|
|
b09fe4a3a7 | ||
|
|
69882ff4cf | ||
|
|
bb337fa0a9 | ||
|
|
e9d1dfac84 | ||
|
|
50b492c64a | ||
|
|
8e65afa994 | ||
|
|
ea22695aa7 | ||
|
|
472308ec71 | ||
|
|
b30aeb1777 | ||
|
|
2b071ad775 | ||
|
|
88fd75b4c7 | ||
|
|
39d1fad8cc | ||
|
|
9af3d2b914 | ||
|
|
fc6c2fbc18 | ||
|
|
2e10d7223a | ||
|
|
e36a53eea6 | ||
|
|
3757ddf9df | ||
|
|
ed31b6a527 | ||
|
|
f4c77c85bd | ||
|
|
85f401f209 | ||
|
|
ef6d862671 | ||
|
|
7e06d535ab |
104
.coveragerc
Normal file
@@ -0,0 +1,104 @@
|
||||
[run]
|
||||
source = homeassistant
|
||||
|
||||
omit =
|
||||
homeassistant/__main__.py
|
||||
|
||||
# omit pieces of code that rely on external devices being present
|
||||
homeassistant/components/arduino.py
|
||||
homeassistant/components/*/arduino.py
|
||||
|
||||
homeassistant/components/isy994.py
|
||||
homeassistant/components/*/isy994.py
|
||||
|
||||
homeassistant/components/modbus.py
|
||||
homeassistant/components/*/modbus.py
|
||||
|
||||
homeassistant/components/*/tellstick.py
|
||||
homeassistant/components/*/vera.py
|
||||
|
||||
homeassistant/components/verisure.py
|
||||
homeassistant/components/*/verisure.py
|
||||
|
||||
homeassistant/components/wink.py
|
||||
homeassistant/components/*/wink.py
|
||||
|
||||
homeassistant/components/zwave.py
|
||||
homeassistant/components/*/zwave.py
|
||||
|
||||
homeassistant/components/ifttt.py
|
||||
homeassistant/components/browser.py
|
||||
homeassistant/components/camera/*
|
||||
homeassistant/components/device_tracker/actiontec.py
|
||||
homeassistant/components/device_tracker/aruba.py
|
||||
homeassistant/components/device_tracker/asuswrt.py
|
||||
homeassistant/components/device_tracker/ddwrt.py
|
||||
homeassistant/components/device_tracker/luci.py
|
||||
homeassistant/components/device_tracker/netgear.py
|
||||
homeassistant/components/device_tracker/nmap_tracker.py
|
||||
homeassistant/components/device_tracker/thomson.py
|
||||
homeassistant/components/device_tracker/tomato.py
|
||||
homeassistant/components/device_tracker/tplink.py
|
||||
homeassistant/components/discovery.py
|
||||
homeassistant/components/downloader.py
|
||||
homeassistant/components/keyboard.py
|
||||
homeassistant/components/light/hue.py
|
||||
homeassistant/components/light/limitlessled.py
|
||||
homeassistant/components/media_player/cast.py
|
||||
homeassistant/components/media_player/denon.py
|
||||
homeassistant/components/media_player/itunes.py
|
||||
homeassistant/components/media_player/kodi.py
|
||||
homeassistant/components/media_player/mpd.py
|
||||
homeassistant/components/media_player/plex.py
|
||||
homeassistant/components/media_player/squeezebox.py
|
||||
homeassistant/components/media_player/sonos.py
|
||||
homeassistant/components/notify/file.py
|
||||
homeassistant/components/notify/instapush.py
|
||||
homeassistant/components/notify/nma.py
|
||||
homeassistant/components/notify/pushbullet.py
|
||||
homeassistant/components/notify/pushover.py
|
||||
homeassistant/components/notify/slack.py
|
||||
homeassistant/components/notify/smtp.py
|
||||
homeassistant/components/notify/syslog.py
|
||||
homeassistant/components/notify/xmpp.py
|
||||
homeassistant/components/sensor/arest.py
|
||||
homeassistant/components/sensor/bitcoin.py
|
||||
homeassistant/components/sensor/command_sensor.py
|
||||
homeassistant/components/sensor/dht.py
|
||||
homeassistant/components/sensor/efergy.py
|
||||
homeassistant/components/sensor/forecast.py
|
||||
homeassistant/components/sensor/glances.py
|
||||
homeassistant/components/sensor/mysensors.py
|
||||
homeassistant/components/sensor/openweathermap.py
|
||||
homeassistant/components/sensor/rest.py
|
||||
homeassistant/components/sensor/rfxtrx.py
|
||||
homeassistant/components/sensor/rpi_gpio.py
|
||||
homeassistant/components/sensor/sabnzbd.py
|
||||
homeassistant/components/sensor/swiss_public_transport.py
|
||||
homeassistant/components/sensor/systemmonitor.py
|
||||
homeassistant/components/sensor/temper.py
|
||||
homeassistant/components/sensor/time_date.py
|
||||
homeassistant/components/sensor/transmission.py
|
||||
homeassistant/components/sensor/worldclock.py
|
||||
homeassistant/components/switch/arest.py
|
||||
homeassistant/components/switch/command_switch.py
|
||||
homeassistant/components/switch/edimax.py
|
||||
homeassistant/components/switch/hikvisioncam.py
|
||||
homeassistant/components/switch/rpi_gpio.py
|
||||
homeassistant/components/switch/transmission.py
|
||||
homeassistant/components/switch/wemo.py
|
||||
homeassistant/components/thermostat/nest.py
|
||||
|
||||
|
||||
[report]
|
||||
# Regexes for lines to exclude from consideration
|
||||
exclude_lines =
|
||||
# Have to re-enable the standard pragma
|
||||
pragma: no cover
|
||||
|
||||
# Don't complain about missing debug-only code:
|
||||
def __repr__
|
||||
|
||||
# Don't complain if tests don't hit defensive assertion code:
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
30
.gitignore
vendored
@@ -1,15 +1,20 @@
|
||||
home-assistant.log
|
||||
home-assistant.conf
|
||||
known_devices.csv
|
||||
config/*
|
||||
!config/home-assistant.conf.default
|
||||
homeassistant/components/frontend/www_static/polymer/bower_components/*
|
||||
|
||||
# There is not a better solution afaik..
|
||||
!config/custom_components
|
||||
config/custom_components/*
|
||||
!config/custom_components/example.py
|
||||
!config/custom_components/hello_world.py
|
||||
!config/custom_components/mqtt_example.py
|
||||
|
||||
tests/config/home-assistant.log
|
||||
|
||||
# Hide sublime text stuff
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Hide code validator output
|
||||
pep8.txt
|
||||
pylint.txt
|
||||
|
||||
# Hide some OS X stuff
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
@@ -21,6 +26,9 @@ Icon
|
||||
|
||||
.idea
|
||||
|
||||
# pytest
|
||||
.cache
|
||||
|
||||
# GITHUB Proposed Python stuff:
|
||||
*.py[cod]
|
||||
|
||||
@@ -56,4 +64,10 @@ nosetests.xml
|
||||
# Mr Developer
|
||||
.mr.developer.cfg
|
||||
.project
|
||||
.pydevproject
|
||||
.pydevproject
|
||||
|
||||
.python-version
|
||||
|
||||
# venv stuff
|
||||
pyvenv.cfg
|
||||
pip-selfcheck.json
|
||||
|
||||
9
.gitmodules
vendored
@@ -1,6 +1,3 @@
|
||||
[submodule "homeassistant/external/pychromecast"]
|
||||
path = homeassistant/external/pychromecast
|
||||
url = https://github.com/balloob/pychromecast.git
|
||||
[submodule "homeassistant/external/pynetgear"]
|
||||
path = homeassistant/external/pynetgear
|
||||
url = https://github.com/balloob/pynetgear.git
|
||||
[submodule "homeassistant/components/frontend/www_static/home-assistant-polymer"]
|
||||
path = homeassistant/components/frontend/www_static/home-assistant-polymer
|
||||
url = https://github.com/balloob/home-assistant-polymer.git
|
||||
|
||||
11
.travis.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
sudo: false
|
||||
language: python
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/virtualenv/python3.4.2/
|
||||
python:
|
||||
- "3.4"
|
||||
install:
|
||||
- script/bootstrap_server
|
||||
script:
|
||||
- script/cibuild
|
||||
72
CONTRIBUTING.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 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?
|
||||
|
||||
The process is straight-forward.
|
||||
|
||||
- Fork the Home Assistant [git repository](https://github.com/balloob/home-assistant).
|
||||
- Write the code for your device, notification service, sensor, or IoT thing.
|
||||
- Check it with ``pylint`` and ``flake8``.
|
||||
- Create a Pull Request against the [**dev**](https://github.com/balloob/home-assistant/tree/dev) branch of Home Assistant.
|
||||
|
||||
Still interested? Then you should read the next sections and get more details.
|
||||
|
||||
## Adding support for a new device
|
||||
|
||||
For help on building your component, please see the [developer documentation](https://home-assistant.io/developers/) on [home-assistant.io](https://home-assistant.io/).
|
||||
|
||||
After you finish adding support for your device:
|
||||
|
||||
- Update the supported devices in the `README.md` file.
|
||||
- Add any new dependencies to `requirements_all.txt`. There is no ordering right now, so just add it to the end.
|
||||
- Update the `.coveragerc` file.
|
||||
- Provide some documentation for [home-assistant.io](https://home-assistant.io/). The documentation is handled in a separate [git repository](https://github.com/balloob/home-assistant.io).
|
||||
- Make sure all your code passes Pylint and flake8 (PEP8 and some more) validation. To generate reports, run `pylint homeassistant > pylint.txt` and `flake8 homeassistant --exclude bower_components,external > flake8.txt`.
|
||||
- Create a Pull Request against the [**dev**](https://github.com/balloob/home-assistant/tree/dev) branch of Home Assistant.
|
||||
- Check for comments and suggestions on your Pull Request and keep an eye on the [Travis output](https://travis-ci.org/balloob/home-assistant/).
|
||||
|
||||
If you've added a component:
|
||||
|
||||
- Update the file [`home-assistant-icons.html`](https://github.com/balloob/home-assistant/blob/master/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-icons.html) with an icon for your domain ([pick one from this list](https://www.polymer-project.org/1.0/components/core-elements/demo.html#core-icon)).
|
||||
- Update the demo component with two states that it provides
|
||||
- Add your component to home-assistant.conf.example
|
||||
|
||||
Since you've updated `home-assistant-icons.html`, you've made changes to the frontend:
|
||||
|
||||
- Run `build_frontend`. This will build a new version of the frontend. Make sure you add the changed files `frontend.py` and `frontend.html` to the commit.
|
||||
|
||||
### Setting states
|
||||
|
||||
It is the responsibility of the component to maintain the states of the devices in your domain. Each device should be a single state and, if possible, a group should be provided that tracks the combined state of the devices.
|
||||
|
||||
A state can have several attributes that will help the frontend in displaying your state:
|
||||
|
||||
- `friendly_name`: this name will be used as the name of the device
|
||||
- `entity_picture`: this picture will be shown instead of the domain icon
|
||||
- `unit_of_measurement`: this will be appended to the state in the interface
|
||||
- `hidden`: This is a suggestion to the frontend on if the state should be hidden
|
||||
|
||||
These attributes are defined in [homeassistant.components](https://github.com/balloob/home-assistant/blob/master/homeassistant/components/__init__.py#L25).
|
||||
|
||||
### Proper Visibility Handling
|
||||
|
||||
Generally, when creating a new entity for Home Assistant you will want it to be a class that inherits the [homeassistant.helpers.entity.Entity](https://github.com/balloob/home-assistant/blob/master/homeassistant/helpers/entity.py) class. If this is done, visibility will be handled for you.
|
||||
You can set a suggestion for your entity's visibility by setting the hidden property by doing something similar to the following.
|
||||
|
||||
```python
|
||||
self.hidden = True
|
||||
```
|
||||
|
||||
This will SUGGEST that the active frontend hides the entity. This requires that the active frontend support hidden cards (the default frontend does) and that the value of hidden be included in your attributes dictionary (see above). The Entity abstract class will take care of this for you.
|
||||
|
||||
Remember: The suggestion set by your component's code will always be overwritten by user settings in the configuration.yaml file. This is why you may set hidden to be False, but the property may remain True (or vice-versa).
|
||||
|
||||
### Working on the frontend
|
||||
|
||||
The frontend is composed of [Polymer](https://www.polymer-project.org) web-components and compiled into the file `frontend.html`. During development you do not want to work with the compiled version but with the seperate files. To have Home Assistant serve the seperate files, set `development=1` for the *http-component* in your config.
|
||||
|
||||
When you are done with development and ready to commit your changes, run `build_frontend`, set `development=0` in your config and validate that everything still works.
|
||||
|
||||
### Notes on PyLint and PEP8 validation
|
||||
|
||||
In case a PyLint warning cannot be avoided, add a comment to disable the PyLint check for that line. This can be done using the format `# pylint: disable=YOUR-ERROR-NAME`. Example of an unavoidable PyLint warning is if you do not use the passed in datetime if you're listening for time change.
|
||||
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM python:3-onbuild
|
||||
MAINTAINER Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>
|
||||
|
||||
VOLUME /config
|
||||
|
||||
RUN pip3 install --no-cache-dir -r requirements_all.txt
|
||||
|
||||
# For the nmap tracker
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends nmap net-tools && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Open Z-Wave disabled because broken
|
||||
#RUN apt-get update && \
|
||||
# apt-get install -y cython3 libudev-dev && \
|
||||
# apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
# pip3 install cython && \
|
||||
# scripts/build_python_openzwave
|
||||
|
||||
CMD [ "python", "-m", "homeassistant", "--config", "/config" ]
|
||||
2
MANIFEST.in
Normal file
@@ -0,0 +1,2 @@
|
||||
recursive-exclude tests *
|
||||
recursive-include homeassistant services.yaml
|
||||
253
README.md
@@ -1,240 +1,37 @@
|
||||
Home Assistant
|
||||
==============
|
||||
# Home Assistant [](https://travis-ci.org/balloob/home-assistant) [](https://coveralls.io/r/balloob/home-assistant?branch=master) [](https://gitter.im/balloob/home-assistant?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
|
||||
Home Assistant provides a platform for home automation. It does so by having modules that observe and trigger actors to do various tasks.
|
||||
[demo]: https://home-assistant.io/demo/
|
||||
|
||||
It is currently able to do the following things:
|
||||
* Track if devices are home by monitoring connected devices to a wireless router (currently supporting modern Netgear routers or routers running Tomato firmware)
|
||||
* Track and control lights
|
||||
* Track and control WeMo switches
|
||||
* Track and control Chromecasts
|
||||
* Turn on the lights when people get home when the sun is setting or has set
|
||||
* Slowly turn on the lights to compensate for light loss when the sun sets and people are home
|
||||
* Turn off lights and connected devices when everybody leaves the house
|
||||
* Download files to the host machine
|
||||
* Open a url in the default browser at the host machine
|
||||
* Simulate key presses on the host for Play/Pause, Next track, Prev track, Volume up, Volume Down
|
||||
* Controllable via a REST API and web interface
|
||||
* Support for remoting Home Assistant instances through a Python API
|
||||
* Android Tasker project to control Home Assistant from your phone and report charging state.
|
||||
Home Assistant is a home automation platform running on Python 3. The goal of Home Assistant is to be able to track and control all devices at home and offer a platform for automating control.
|
||||
|
||||

|
||||
|
||||
Current compatible devices:
|
||||
* [WeMo switches](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/)
|
||||
* [Philips Hue](http://meethue.com)
|
||||
* [Google Chromecast](http://www.google.com/intl/en/chrome/devices/chromecast)
|
||||
* Wireless router running [Tomato firmware](http://www.polarcloud.com/tomato)
|
||||
* Netgear wireless routers (tested with R6300)
|
||||
|
||||
The system is built modular so support for other devices or actions can be implemented easily.
|
||||
|
||||
Installation instructions
|
||||
-------------------------
|
||||
* The core depends on [PyEphem](http://rhodesmill.org/pyephem/) and [Requests](http://python-requests.org). Depending on the components you would like to use you will need [PHue](https://github.com/studioimaginaire/phue) for Philips Hue support, [PyChromecast](https://github.com/balloob/pychromecast) for Chromecast support and [ouimeaux](https://github.com/iancmcc/ouimeaux) for WeMo support. Install these using `pip install pyephem requests phue ouimeaux pychromecast`.
|
||||
* Clone the repository and pull in the submodules `git clone --recursive https://github.com/balloob/home-assistant.git`
|
||||
* Copy home-assistant.conf.default to home-assistant.conf and adjust the config values to match your setup.
|
||||
* For Tomato you will have to not only setup your host, username and password but also a http_id. The http_id can be retrieved by going to the admin console of your router, view the source of any of the pages and search for `http_id`.
|
||||
* If you want to use Hue, setup PHue by running `python -m phue --host HUE_BRIDGE_IP_ADDRESS` from the commandline and follow the instructions.
|
||||
* While running the script it will create and maintain a file called `known_devices.csv` which will contain the detected devices. Adjust the track variable for the devices you want the script to act on and restart the script or call the service `device_tracker/reload_devices_csv`.
|
||||
|
||||
Done. Start it now by running `python start.py`
|
||||
|
||||
Web interface and API
|
||||
---------------------
|
||||
Home Assistent runs a webserver accessible on port 8123.
|
||||
|
||||
* At http://localhost:8123/ it will provide a debug interface showing the current state of the system and an overview of registered services.
|
||||
* At http://localhost:8123/api/ it provides a password protected API.
|
||||
|
||||
A screenshot of the debug interface:
|
||||

|
||||
|
||||
All API calls have to be accompanied by an 'api_password' parameter (as specified in `home-assistant.conf`) and will
|
||||
return JSON encoded objects. If successful calls will return status code 200 or 201.
|
||||
|
||||
Other status codes that can occur are:
|
||||
- 400 (Bad Request)
|
||||
- 401 (Unauthorized)
|
||||
- 404 (Not Found)
|
||||
- 405 (Method not allowed)
|
||||
|
||||
The api supports the following actions:
|
||||
|
||||
**/api/states - GET**<br>
|
||||
Returns a list of entity ids for which a state is available
|
||||
|
||||
```json
|
||||
{
|
||||
"entity_ids": [
|
||||
"Paulus_Nexus_4",
|
||||
"weather.sun",
|
||||
"all_devices"
|
||||
]
|
||||
}
|
||||
To get started:
|
||||
```bash
|
||||
python3 -m pip install homeassistant
|
||||
hass --open-ui
|
||||
```
|
||||
|
||||
**/api/events - GET**<br>
|
||||
Returns a dict with as keys the events and as value the number of listeners.
|
||||
Check out [the website](https://home-assistant.io) for [a demo][demo], installation instructions, tutorials and documentation.
|
||||
|
||||
```json
|
||||
{
|
||||
"event_listeners": {
|
||||
"state_changed": 5,
|
||||
"time_changed": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
[][demo]
|
||||
|
||||
**/api/services - GET**<br>
|
||||
Returns a dict with as keys the domain and as value a list of published services.
|
||||
Examples of devices it can interface it:
|
||||
|
||||
```json
|
||||
{
|
||||
"services": {
|
||||
"browser": [
|
||||
"browse_url"
|
||||
],
|
||||
"keyboard": [
|
||||
"volume_up",
|
||||
"volume_down"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
* Monitoring connected devices to a wireless router: [OpenWrt](https://openwrt.org/), [Tomato](http://www.polarcloud.com/tomato), [Netgear](http://netgear.com), [DD-WRT](http://www.dd-wrt.com/site/index), [TPLink](http://www.tp-link.us/), and [ASUSWRT](http://event.asus.com/2013/nw/ASUSWRT/)
|
||||
* [Philips Hue](http://meethue.com) lights, [WeMo](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/) switches, [Edimax](http://www.edimax.com/) switches, [Efergy](https://efergy.com) energy monitoring, RFXtrx sensors, and [Tellstick](http://www.telldus.se/products/tellstick) devices and sensors
|
||||
* [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/), [Logitech Squeezebox](https://en.wikipedia.org/wiki/Squeezebox_%28network_music_player%29), [Kodi (XBMC)](http://kodi.tv/), and iTunes (by way of [itunes-api](https://github.com/maddox/itunes-api))
|
||||
* Support for [ISY994](https://www.universal-devices.com/residential/isy994i-series/) (Insteon and X10 devices), [Z-Wave](http://www.z-wave.com/), [Nest Thermostats](https://nest.com/), [Arduino](https://www.arduino.cc/), [Raspberry Pi](https://www.raspberrypi.org/), and [Modbus](http://www.modbus.org/)
|
||||
* Integrate data from the [Bitcoin](https://bitcoin.org) network, meteorological data from [OpenWeatherMap](http://openweathermap.org/) and [Forecast.io](https://forecast.io/), [Transmission](http://www.transmissionbt.com/), or [SABnzbd](http://sabnzbd.org).
|
||||
* [See full list of supported devices](https://home-assistant.io/components/)
|
||||
|
||||
**/api/states/<entity_id>** - GET<br>
|
||||
Returns the current state from an entity
|
||||
Built home automation on top of your devices:
|
||||
|
||||
```json
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
```
|
||||
* Keep a precise history of every change to the state of your house
|
||||
* Turn on the lights when people get home after sun set
|
||||
* Turn on lights slowly during sun set to compensate for less light
|
||||
* Turn off all lights and devices when everybody leaves the house
|
||||
* Offers a [REST API](https://home-assistant.io/developers/api.html) and can interface with MQTT for easy integration with other projects
|
||||
* Allow sending notifications using [Instapush](https://instapush.im), [Notify My Android (NMA)](http://www.notifymyandroid.com/), [PushBullet](https://www.pushbullet.com/), [PushOver](https://pushover.net/), [Slack](https://slack.com/), and [Jabber (XMPP)](http://xmpp.org)
|
||||
|
||||
**/api/states/<entity_id>** - POST<br>
|
||||
Updates the current state of an entity. Returns status code 201 if successful with location header of updated resource and the new state in the body.<br>
|
||||
parameter: new_state - string<br>
|
||||
optional parameter: attributes - JSON encoded object
|
||||
The system is built modular so support for other devices or actions can be implemented easily. See also the [section on architecture](https://home-assistant.io/developers/architecture.html) and the [section on creating your own components](https://home-assistant.io/developers/creating_components.html).
|
||||
|
||||
```json
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
```
|
||||
|
||||
**/api/events/<event_type>** - POST<br>
|
||||
Fires an event with event_type<br>
|
||||
optional parameter: event_data - JSON encoded object
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Event download_file fired."
|
||||
}
|
||||
```
|
||||
|
||||
**/api/services/<domain>/<service>** - POST<br>
|
||||
Calls a service within a specific domain.<br>
|
||||
optional parameter: service_data - JSON encoded object
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Service keyboard/volume_up called."
|
||||
}
|
||||
```
|
||||
|
||||
Android remote control
|
||||
----------------------
|
||||
|
||||
An app has been built using [Tasker for Android](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm) that:
|
||||
|
||||
* Provides buttons to control the lights and the chromecast
|
||||
* Reports the charging state and battery level of the phone
|
||||
|
||||
The [APK](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.apk) and [Tasker project XML](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.prj.xml) can be found in [/android-tasker/](https://github.com/balloob/home-assistant/tree/master/android-tasker)
|
||||
|
||||

|
||||
|
||||
Architecture
|
||||
------------
|
||||
The core of Home Assistant exists of two parts; a Bus for calling services and firing events and a State Machine that keeps track of the state of things.
|
||||
|
||||

|
||||
|
||||
For example to control the lights there are two components. One is the device_tracker that polls the wireless router for connected devices and updates the state of the tracked devices in the State Machine to be either 'Home' or 'Not Home'.
|
||||
|
||||
When a state is changed a state_changed event is fired for which the device_sun_light_trigger component is listening. Based on the new state of the device combined with the state of the sun it will decide if it should turn the lights on or off:
|
||||
|
||||
In the event that the state of device 'Paulus Nexus 5' changes to the 'Home' state:
|
||||
If the sun has set and the lights are not on:
|
||||
Turn on the lights
|
||||
|
||||
In the event that the combined state of all tracked devices changes to 'Not Home':
|
||||
If the lights are on:
|
||||
Turn off the lights
|
||||
|
||||
In the event of the sun setting:
|
||||
If the lights are off and the combined state of all tracked device equals 'Home':
|
||||
Turn on the lights
|
||||
|
||||
By using the Bus as a central communication hub between components it is easy to replace components or add functionality. For example if you would want to change the way devices are detected you only have to write a component that updates the device states in the State Machine.
|
||||
|
||||
### Components
|
||||
|
||||
**sun**
|
||||
Tracks the state of the sun and when the next sun rising and setting will occur.
|
||||
Depends on: latitude and longitude
|
||||
Action: maintains state of `weather.sun` including attributes `next_rising` and `next_setting`
|
||||
|
||||
**device_tracker**
|
||||
Keeps track of which devices are currently home.
|
||||
Action: sets the state per device and maintains a combined state called `all_devices`. Keeps track of known devices in the file `known_devices.csv`.
|
||||
|
||||
**Light**
|
||||
Keeps track which lights are turned on and can control the lights.
|
||||
|
||||
**WeMo**
|
||||
Keeps track which WeMo switches are in the network and allows you to control them.
|
||||
|
||||
**device_sun_light_trigger**
|
||||
Turns lights on or off using a light control component based on state of the sun and devices that are home.
|
||||
Depends on: light control, track_sun, DeviceTracker
|
||||
Action:
|
||||
* Turns lights off when all devices leave home.
|
||||
* Turns lights on when a device is home while sun is setting.
|
||||
* Turns lights on when a device gets home after sun set.
|
||||
|
||||
Registers services `light_control/turn_light_on` and `light_control/turn_light_off` to turn a or all lights on or off.
|
||||
|
||||
Optional service data:
|
||||
- `light_id` - only act on specific light. Else targets all.
|
||||
- `transition_seconds` - seconds to take to swithc to new state.
|
||||
|
||||
**chromecast**
|
||||
Registers three services to start playing YouTube video's on the ChromeCast.
|
||||
|
||||
Service `chromecast/play_youtube_video` starts playing the specified video on the YouTube app on the ChromeCast. Specify video using `video` in service_data.
|
||||
|
||||
Service `chromecast/start_fireplace` will start a YouTube movie simulating a fireplace and the `chromecast/start_epic_sax` service will start playing Epic Sax Guy 10h version.
|
||||
|
||||
**media_buttons**
|
||||
Registers services that will simulate key presses on the keyboard. It currently offers the following Buttons as a Service (BaaS): `keyboard/volume_up`, `keyboard/volume_down` and `keyboard/media_play_pause`
|
||||
This actor depends on: PyUserInput
|
||||
|
||||
**downloader**
|
||||
Registers service `downloader/download_file` that will download files. File to download is specified in the `url` field in the service data.
|
||||
|
||||
**browser**
|
||||
Registers service `browser/browse_url` that opens `url` as specified in event_data in the system default browser.
|
||||
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/) how to reach us.
|
||||
|
||||
@@ -1,871 +0,0 @@
|
||||
<TaskerData sr="" dvi="1" tv="4.2u3m">
|
||||
<dmetric>1080.0,1776.0</dmetric>
|
||||
<Profile sr="prof25" ve="2">
|
||||
<cdate>1380613730755</cdate>
|
||||
<clp>true</clp>
|
||||
<edate>1382769497429</edate>
|
||||
<id>25</id>
|
||||
<mid0>23</mid0>
|
||||
<mid1>20</mid1>
|
||||
<nme>HA Power USB</nme>
|
||||
<pri>10</pri>
|
||||
<State sr="con0">
|
||||
<code>10</code>
|
||||
<Int sr="arg0" val="2"/>
|
||||
</State>
|
||||
</Profile>
|
||||
<Profile sr="prof26" ve="2">
|
||||
<cdate>1380613730755</cdate>
|
||||
<clp>true</clp>
|
||||
<edate>1383003483161</edate>
|
||||
<id>26</id>
|
||||
<mid0>22</mid0>
|
||||
<mid1>20</mid1>
|
||||
<nme>HA Power Wireless</nme>
|
||||
<pri>10</pri>
|
||||
<State sr="con0">
|
||||
<code>10</code>
|
||||
<Int sr="arg0" val="3"/>
|
||||
</State>
|
||||
</Profile>
|
||||
<Profile sr="prof3" ve="2">
|
||||
<cdate>1380613730755</cdate>
|
||||
<clp>true</clp>
|
||||
<edate>1383003498566</edate>
|
||||
<id>3</id>
|
||||
<mid0>10</mid0>
|
||||
<mid1>20</mid1>
|
||||
<nme>HA Power AC</nme>
|
||||
<pri>10</pri>
|
||||
<State sr="con0">
|
||||
<code>10</code>
|
||||
<Int sr="arg0" val="1"/>
|
||||
</State>
|
||||
</Profile>
|
||||
<Profile sr="prof5" ve="2">
|
||||
<cdate>1380496514959</cdate>
|
||||
<cldm>1500</cldm>
|
||||
<clp>true</clp>
|
||||
<edate>1382769618501</edate>
|
||||
<id>5</id>
|
||||
<mid0>19</mid0>
|
||||
<nme>HA Battery Changed</nme>
|
||||
<Event sr="con0" ve="2">
|
||||
<code>203</code>
|
||||
<pri>0</pri>
|
||||
</Event>
|
||||
</Profile>
|
||||
<Project sr="proj0">
|
||||
<cdate>1381110247781</cdate>
|
||||
<name>Home Assistant</name>
|
||||
<pids>3,25,26,5</pids>
|
||||
<scenes>Variable Query,Home Assistant Start</scenes>
|
||||
<tids>20,19,9,24,4,8,22,10,30,31,16,6,15,35,13,23,14,11,12,7,28,32,29</tids>
|
||||
<Kid sr="Kid">
|
||||
<launchID>12</launchID>
|
||||
<pkg>nl.paulus.homeassistant</pkg>
|
||||
<vnme>1.2</vnme>
|
||||
<vnum>16</vnum>
|
||||
</Kid>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>cust_animal_penguin</nme>
|
||||
</Img>
|
||||
</Project>
|
||||
<Scene sr="sceneHome Assistant Start">
|
||||
<cdate>1381113309678</cdate>
|
||||
<edate>1381162068611</edate>
|
||||
<heightLand>-1</heightLand>
|
||||
<heightPort>688</heightPort>
|
||||
<nme>Home Assistant Start</nme>
|
||||
<widthLand>-1</widthLand>
|
||||
<widthPort>523</widthPort>
|
||||
<TextElement sr="elements0" ve="2">
|
||||
<flags>4</flags>
|
||||
<geom>0,17,523,107,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">TextTitle</Str>
|
||||
<Str sr="arg1" ve="3">Home Assistant</Str>
|
||||
<Int sr="arg2" val="33"/>
|
||||
<Int sr="arg3" val="100"/>
|
||||
<Str sr="arg4" ve="3">#FFFFFFFF</Str>
|
||||
<Int sr="arg5" val="0"/>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="0"/>
|
||||
</TextElement>
|
||||
<ListElement sr="elements1">
|
||||
<flags>4</flags>
|
||||
<geom>23,136,477,514,-1,-1,-1,-1</geom>
|
||||
<itemclickTask>13</itemclickTask>
|
||||
<Str sr="arg0" ve="3">Menu1</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
<Scene sr="arg4">
|
||||
<Scene sr="val">
|
||||
<cdate>1381113396824</cdate>
|
||||
<edate>1381113396824</edate>
|
||||
<heightLand>-1</heightLand>
|
||||
<heightPort>100</heightPort>
|
||||
<nme>Builtin Item Layout</nme>
|
||||
<widthLand>-1</widthLand>
|
||||
<widthPort>440</widthPort>
|
||||
<ImageElement sr="elements0">
|
||||
<flags>5</flags>
|
||||
<geom>340,10,90,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Icon</Str>
|
||||
<Img sr="arg1" ve="2">
|
||||
<nme>hd_aaa_ext_tiles_small</nme>
|
||||
</Img>
|
||||
<Int sr="arg2" val="255"/>
|
||||
</ImageElement>
|
||||
<TextElement sr="elements1" ve="2">
|
||||
<flags>5</flags>
|
||||
<geom>60,10,270,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Label</Str>
|
||||
<Str sr="arg1" ve="3"/>
|
||||
<Int sr="arg2" val="18"/>
|
||||
<Int sr="arg3"/>
|
||||
<Str sr="arg4" ve="3">#FFFFFFFF</Str>
|
||||
<Int sr="arg5" val="3"/>
|
||||
<Int sr="arg6"/>
|
||||
<Int sr="arg7"/>
|
||||
</TextElement>
|
||||
<TextElement sr="elements2" ve="2">
|
||||
<flags>1</flags>
|
||||
<geom>10,10,40,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Index</Str>
|
||||
<Str sr="arg1" ve="3">1.</Str>
|
||||
<Int sr="arg2" val="18"/>
|
||||
<Int sr="arg3"/>
|
||||
<Str sr="arg4" ve="3">#FFFFFFFF</Str>
|
||||
<Int sr="arg5" val="3"/>
|
||||
<Int sr="arg6"/>
|
||||
<Int sr="arg7"/>
|
||||
</TextElement>
|
||||
<PropertiesElement sr="props">
|
||||
<Int sr="arg0" val="1"/>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#00000000</Str>
|
||||
<Int sr="arg3" val="0"/>
|
||||
<Str sr="arg4" ve="3">Builtin Item Layout</Str>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Img sr="arg6" ve="2"/>
|
||||
<Str sr="arg7" ve="3"/>
|
||||
</PropertiesElement>
|
||||
</Scene>
|
||||
</Scene>
|
||||
<Int sr="arg5" val="1"/>
|
||||
<Int sr="arg6" val="1"/>
|
||||
<RectElement sr="background">
|
||||
<flags>4</flags>
|
||||
<geom>-1,-1,-1,-1,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3"/>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#77333333</Str>
|
||||
<Str sr="arg3" ve="3">#77333333</Str>
|
||||
<Int sr="arg4" val="0"/>
|
||||
<Str sr="arg5" ve="3">#FF000000</Str>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="0"/>
|
||||
</RectElement>
|
||||
<ListElementItem sr="item0">
|
||||
<label>Light On</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Light On</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_aaa_ext_sun</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
<ListElementItem sr="item1">
|
||||
<label>Light Off</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Light Off</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_device_access_bightness_low</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
<ListElementItem sr="item2">
|
||||
<label>Start Fireplace</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Start Fireplace</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_aaa_ext_coffee</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
<ListElementItem sr="item3">
|
||||
<label>Start Epic Sax</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Start Epic Sax</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_aaa_ext_guitar</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
<ListElementItem sr="item4">
|
||||
<label>Settings</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Setup</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_action_settings</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
</ListElement>
|
||||
<PropertiesElement sr="props">
|
||||
<Int sr="arg0" val="1"/>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#DA000000</Str>
|
||||
<Int sr="arg3" val="0"/>
|
||||
<Str sr="arg4" ve="3">Home Assistant Start</Str>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Img sr="arg6" ve="2"/>
|
||||
<Str sr="arg7" ve="3"/>
|
||||
</PropertiesElement>
|
||||
</Scene>
|
||||
<Scene sr="sceneVariable Query">
|
||||
<cdate>1381112175910</cdate>
|
||||
<edate>1381112254701</edate>
|
||||
<heightLand>-1</heightLand>
|
||||
<heightPort>380</heightPort>
|
||||
<nme>Variable Query</nme>
|
||||
<widthLand>-1</widthLand>
|
||||
<widthPort>440</widthPort>
|
||||
<TextElement sr="elements0" ve="2">
|
||||
<flags>4</flags>
|
||||
<geom>8,0,432,96,8,0,432,96</geom>
|
||||
<Str sr="arg0" ve="3">Title</Str>
|
||||
<Str sr="arg1" ve="3">Title</Str>
|
||||
<Int sr="arg2" val="32"/>
|
||||
<Int sr="arg3"/>
|
||||
<Str sr="arg4" ve="3">#FF0099CC</Str>
|
||||
<Int sr="arg5" val="3"/>
|
||||
<Int sr="arg6"/>
|
||||
<Int sr="arg7"/>
|
||||
</TextElement>
|
||||
<RectElement sr="elements1">
|
||||
<flags>5</flags>
|
||||
<geom>0,96,440,4,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Header</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#77333333</Str>
|
||||
<Str sr="arg3" ve="3">#77333333</Str>
|
||||
<Int sr="arg4" val="0"/>
|
||||
<Str sr="arg5" ve="3">#FF000000</Str>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="0"/>
|
||||
</RectElement>
|
||||
<EditTextElement sr="elements2">
|
||||
<flags>13</flags>
|
||||
<geom>20,156,400,96,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">TextEdit1</Str>
|
||||
<Str sr="arg1" ve="3"/>
|
||||
<Int sr="arg2" val="16"/>
|
||||
<Int sr="arg3" val="100"/>
|
||||
<Str sr="arg4" ve="3">#FFFFFFFF</Str>
|
||||
<Int sr="arg5" val="0"/>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="1000"/>
|
||||
</EditTextElement>
|
||||
<RectElement sr="elements3">
|
||||
<flags>5</flags>
|
||||
<geom>0,300,440,4,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Footer</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#77333333</Str>
|
||||
<Str sr="arg3" ve="3">#77333333</Str>
|
||||
<Int sr="arg4" val="0"/>
|
||||
<Str sr="arg5" ve="3">#FF000000</Str>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="0"/>
|
||||
</RectElement>
|
||||
<ImageElement sr="elements4">
|
||||
<clickTask>-936</clickTask>
|
||||
<flags>4</flags>
|
||||
<geom>70,300,80,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Accept</Str>
|
||||
<Img sr="arg1" ve="2">
|
||||
<nme>hd_navigation_accept</nme>
|
||||
</Img>
|
||||
<Int sr="arg2" val="255"/>
|
||||
</ImageElement>
|
||||
<ImageElement sr="elements5">
|
||||
<clickTask>-936</clickTask>
|
||||
<flags>4</flags>
|
||||
<geom>290,300,80,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Cancel</Str>
|
||||
<Img sr="arg1" ve="2">
|
||||
<nme>hd_content_remove</nme>
|
||||
</Img>
|
||||
<Int sr="arg2" val="255"/>
|
||||
</ImageElement>
|
||||
<PropertiesElement sr="props">
|
||||
<Int sr="arg0" val="1"/>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#FF000000</Str>
|
||||
<Int sr="arg3" val="0"/>
|
||||
<Str sr="arg4" ve="3">Variable Query</Str>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Img sr="arg6" ve="2"/>
|
||||
<Str sr="arg7" ve="3"/>
|
||||
</PropertiesElement>
|
||||
</Scene>
|
||||
<Task sr="task10">
|
||||
<cdate>1380613530339</cdate>
|
||||
<edate>1383030846230</edate>
|
||||
<id>10</id>
|
||||
<nme>Charging AC</nme>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">_Update Charging</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">ac</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task11">
|
||||
<cdate>1381110672417</cdate>
|
||||
<edate>1384035370683</edate>
|
||||
<id>11</id>
|
||||
<nme>Open Debug Interface</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>104</code>
|
||||
<Str sr="arg0" ve="3">http://%HA_HOST:%HA_PORT/?api_password=%HA_API_PASSWORD</Str>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task12">
|
||||
<cdate>1381113015963</cdate>
|
||||
<edate>1384219718372</edate>
|
||||
<id>12</id>
|
||||
<nme>Start Screen</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>47</code>
|
||||
<Str sr="arg0" ve="3">Home Assistant Start</Str>
|
||||
<Int sr="arg1" val="5"/>
|
||||
<Int sr="arg2" val="100"/>
|
||||
<Int sr="arg3" val="100"/>
|
||||
<Int sr="arg4" val="1"/>
|
||||
<Int sr="arg5" val="0"/>
|
||||
<Int sr="arg6" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>49</code>
|
||||
<Str sr="arg0" ve="3">Home Assistant Start</Str>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>cust_animal_penguin</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task13">
|
||||
<cdate>1381114398467</cdate>
|
||||
<edate>1381114398467</edate>
|
||||
<id>13</id>
|
||||
<pri>11</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>49</code>
|
||||
<lhs>%tap_label</lhs>
|
||||
<op>2</op>
|
||||
<rhs>Settings</rhs>
|
||||
<Str sr="arg0" ve="3">Home Assistant Start</Str>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task14">
|
||||
<cdate>1381114829583</cdate>
|
||||
<edate>1385537340259</edate>
|
||||
<id>14</id>
|
||||
<nme>API Fire Event</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>116</code>
|
||||
<Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">/api/events/%par1</Str>
|
||||
<Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD</Str>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>548</code>
|
||||
<Str sr="arg0" ve="3">Fired event %par1</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task15">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386787405570</edate>
|
||||
<id>15</id>
|
||||
<nme>Light On</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">light/turn_on</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hd_aaa_ext_sun</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task16">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1385172575157</edate>
|
||||
<id>16</id>
|
||||
<nme>Start Epic Sax</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">chromecast/start_epic_sax</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hd_aaa_ext_guitar</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task19">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386695312804</edate>
|
||||
<id>19</id>
|
||||
<nme>Update Battery</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>547</code>
|
||||
<label>Make sure charging var exist</label>
|
||||
<lhs>%HA_CHARGING</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_CHARGING</Str>
|
||||
<Str sr="arg1" ve="3">none</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>116</code>
|
||||
<Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">/api/states/devices.%HA_DEVICE_NAME.charging</Str>
|
||||
<Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD
|
||||
new_state=%HA_CHARGING
|
||||
attributes={"battery":%BATT}</Str>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task20">
|
||||
<cdate>1380613530339</cdate>
|
||||
<edate>1386695398714</edate>
|
||||
<id>20</id>
|
||||
<nme>Charging None</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">_Update Charging</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">none</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task22">
|
||||
<cdate>1380613530339</cdate>
|
||||
<edate>1383030909347</edate>
|
||||
<id>22</id>
|
||||
<nme>Charging Wireless</nme>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">_Update Charging</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">wireless</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task23">
|
||||
<cdate>1380613530339</cdate>
|
||||
<edate>1383030849758</edate>
|
||||
<id>23</id>
|
||||
<nme>Charging USB</nme>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">_Update Charging</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">usb</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task24">
|
||||
<cdate>1381114829583</cdate>
|
||||
<edate>1385537314797</edate>
|
||||
<id>24</id>
|
||||
<nme>API Call Service</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>116</code>
|
||||
<Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">/api/services/%par1</Str>
|
||||
<Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD</Str>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>548</code>
|
||||
<Str sr="arg0" ve="3">Called service %par1</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task28">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172806993</edate>
|
||||
<id>28</id>
|
||||
<nme>Volume Down</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/volume_down</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_images_rotate_left</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task29">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172552470</edate>
|
||||
<id>29</id>
|
||||
<nme>Play Pause</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/media_play_pause</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_av_pause</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task30">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172803463</edate>
|
||||
<id>30</id>
|
||||
<nme>Volume Mute Toggle</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/volume_mute</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_device_access_volume_muted</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task31">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172559562</edate>
|
||||
<id>31</id>
|
||||
<nme>Next Track</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/media_next_track</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_av_next</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task32">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172567948</edate>
|
||||
<id>32</id>
|
||||
<nme>Prev Track</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/media_prev_track</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_av_previous</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task35">
|
||||
<cdate>1381114829583</cdate>
|
||||
<edate>1385537324133</edate>
|
||||
<id>35</id>
|
||||
<nme>API Call Service With Data</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>116</code>
|
||||
<Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">/api/services/%par1</Str>
|
||||
<Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD
|
||||
service_data=%par2</Str>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>548</code>
|
||||
<Str sr="arg0" ve="3">Called service %par1</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task4">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386787393520</edate>
|
||||
<id>4</id>
|
||||
<nme>Light Off</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">light/turn_off</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_device_access_bightness_low</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task6">
|
||||
<cdate>1380522560890</cdate>
|
||||
<edate>1383958813434</edate>
|
||||
<id>6</id>
|
||||
<nme>Setup</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>118</code>
|
||||
<lhs>%HA_HOST</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">icanhazip.com</Str>
|
||||
<Str sr="arg1" ve="3"/>
|
||||
<Str sr="arg2" ve="3"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3">%HA_HOST</Str>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>547</code>
|
||||
<lhs>%HA_HOST</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_HOST</Str>
|
||||
<Str sr="arg1" ve="3">%HTTPD</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act2" ve="3">
|
||||
<code>547</code>
|
||||
<lhs>%HA_PORT</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">8123</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act3" ve="3">
|
||||
<code>547</code>
|
||||
<lhs>%HA_API_PASSWORD</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_API_PASSWORD</Str>
|
||||
<Str sr="arg1" ve="3">My password</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act4" ve="3">
|
||||
<code>547</code>
|
||||
<lhs>%HA_DEVICE_NAME</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_DEVICE_NAME</Str>
|
||||
<Str sr="arg1" ve="3">%DEVMOD</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act5" ve="3">
|
||||
<code>595</code>
|
||||
<Str sr="arg0" ve="3">Host</Str>
|
||||
<Str sr="arg1" ve="3">%HA_HOST</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Str sr="arg3" ve="3">%HA_HOST</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3">Variable Query</Str>
|
||||
<Int sr="arg6" val="40"/>
|
||||
<Int sr="arg7" val="1"/>
|
||||
</Action>
|
||||
<Action sr="act6" ve="3">
|
||||
<code>595</code>
|
||||
<Str sr="arg0" ve="3">Port</Str>
|
||||
<Str sr="arg1" ve="3">%HA_PORT</Str>
|
||||
<Int sr="arg2" val="4"/>
|
||||
<Str sr="arg3" ve="3">%HA_PORT</Str>
|
||||
<Str sr="arg4" ve="3">%HA_PORT</Str>
|
||||
<Str sr="arg5" ve="3">Variable Query</Str>
|
||||
<Int sr="arg6" val="40"/>
|
||||
<Int sr="arg7" val="1"/>
|
||||
</Action>
|
||||
<Action sr="act7" ve="3">
|
||||
<code>595</code>
|
||||
<Str sr="arg0" ve="3">API Password</Str>
|
||||
<Str sr="arg1" ve="3">%HA_API_PASSWORD</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Str sr="arg3" ve="3">%HA_API_PASSWORD</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3">Variable Query</Str>
|
||||
<Int sr="arg6" val="40"/>
|
||||
<Int sr="arg7" val="1"/>
|
||||
</Action>
|
||||
<Action sr="act8" ve="3">
|
||||
<code>595</code>
|
||||
<label>Ask device name</label>
|
||||
<Str sr="arg0" ve="3">Device name</Str>
|
||||
<Str sr="arg1" ve="3">%HA_DEVICE_NAME</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Str sr="arg3" ve="3">%HA_DEVICE_NAME</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3">Variable Query</Str>
|
||||
<Int sr="arg6" val="40"/>
|
||||
<Int sr="arg7" val="1"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hd_ab_action_settings</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task7">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1386787431769</edate>
|
||||
<id>7</id>
|
||||
<nme>Volume Up</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/volume_up</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_images_rotate_right</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task8">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386695263222</edate>
|
||||
<id>8</id>
|
||||
<nme>_Update Charging</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>547</code>
|
||||
<Str sr="arg0" ve="3">%HA_CHARGING</Str>
|
||||
<Str sr="arg1" ve="3">%par1</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Update Battery</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task9">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386787379497</edate>
|
||||
<id>9</id>
|
||||
<nme>Start Fireplace</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">chromecast/start_fireplace</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hd_aaa_ext_coffee</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
</TaskerData>
|
||||
197
config/configuration.yaml.example
Normal file
@@ -0,0 +1,197 @@
|
||||
homeassistant:
|
||||
# Omitted values in this section will be auto detected using freegeoip.net
|
||||
|
||||
# Location required to calculate the time the sun rises and sets.
|
||||
# Cooridinates are also used for location for weather related components.
|
||||
# Google Maps can be used to determine more precise GPS cooridinates.
|
||||
latitude: 32.87336
|
||||
longitude: 117.22743
|
||||
|
||||
# C for Celcius, F for Fahrenheit
|
||||
temperature_unit: C
|
||||
|
||||
# Pick yours from here:
|
||||
# http://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
time_zone: America/Los_Angeles
|
||||
|
||||
# Name of the location where Home Assistant is running
|
||||
name: Home
|
||||
|
||||
http:
|
||||
api_password: mypass
|
||||
# Set to 1 to enable development mode
|
||||
# development: 1
|
||||
|
||||
light:
|
||||
# platform: hue
|
||||
|
||||
wink:
|
||||
# Get your token at https://winkbearertoken.appspot.com
|
||||
access_token: 'YOUR_TOKEN'
|
||||
|
||||
device_tracker:
|
||||
# The following types are available: ddwrt, netgear, tomato, luci,
|
||||
# and nmap_tracker
|
||||
platform: netgear
|
||||
host: 192.168.1.1
|
||||
username: admin
|
||||
password: PASSWORD
|
||||
# http_id is needed for Tomato routers only
|
||||
# http_id: ABCDEFGHH
|
||||
# For nmap_tracker, only the IP addresses to scan are needed:
|
||||
# hosts: 192.168.1.1/24 # netmask prefix notation or
|
||||
# hosts: 192.168.1.1-255 # address range
|
||||
|
||||
chromecast:
|
||||
|
||||
switch:
|
||||
platform: wemo
|
||||
|
||||
thermostat:
|
||||
platform: nest
|
||||
# Required: username and password that are used to login to the Nest thermostat.
|
||||
username: myemail@mydomain.com
|
||||
password: mypassword
|
||||
|
||||
downloader:
|
||||
download_dir: downloads
|
||||
|
||||
notify:
|
||||
platform: pushbullet
|
||||
api_key: ABCDEFGHJKLMNOPQRSTUVXYZ
|
||||
|
||||
device_sun_light_trigger:
|
||||
# Optional: specify a specific light/group of lights that has to be turned on
|
||||
light_group: group.living_room
|
||||
# Optional: specify which light profile to use when turning lights on
|
||||
light_profile: relax
|
||||
# Optional: disable lights being turned off when everybody leaves the house
|
||||
# disable_turn_off: 1
|
||||
|
||||
# A comma separated list of states that have to be tracked as a single group
|
||||
# Grouped states should share the same type of states (ON/OFF or HOME/NOT_HOME)
|
||||
# You can also have groups within groups.
|
||||
group:
|
||||
Home:
|
||||
- group.living_room
|
||||
- group.kitchen
|
||||
living_room:
|
||||
- light.Bowl
|
||||
- light.Ceiling
|
||||
- light.TV_back_light
|
||||
kitchen:
|
||||
- light.fan_bulb_1
|
||||
- light.fan_bulb_2
|
||||
children:
|
||||
- device_tracker.child_1
|
||||
- device_tracker.child_2
|
||||
|
||||
process:
|
||||
# items are which processes to look for: <entity_id>: <search string within ps>
|
||||
xbmc: XBMC.App
|
||||
|
||||
example:
|
||||
|
||||
simple_alarm:
|
||||
# Which light/light group has to flash when a known device comes home
|
||||
known_light: light.Bowl
|
||||
# Which light/light group has to flash red when light turns on while no one home
|
||||
unknown_light: group.living_room
|
||||
|
||||
browser:
|
||||
|
||||
keyboard:
|
||||
|
||||
automation:
|
||||
- alias: 'Rule 1 Light on in the evening'
|
||||
trigger:
|
||||
- platform: sun
|
||||
event: sunset
|
||||
offset: "-01:00:00"
|
||||
- platform: state
|
||||
entity_id: group.all_devices
|
||||
state: home
|
||||
condition:
|
||||
- platform: state
|
||||
entity_id: group.all_devices
|
||||
state: home
|
||||
- platform: time
|
||||
after: "16:00:00"
|
||||
before: "23:00:00"
|
||||
action:
|
||||
service: homeassistant.turn_on
|
||||
entity_id: group.living_room
|
||||
|
||||
- alias: 'Rule 2 - Away Mode'
|
||||
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: group.all_devices
|
||||
state: 'not_home'
|
||||
|
||||
condition: use_trigger_values
|
||||
action:
|
||||
service: light.turn_off
|
||||
entity_id: group.all_lights
|
||||
|
||||
sensor:
|
||||
platform: systemmonitor
|
||||
resources:
|
||||
- type: 'disk_use_percent'
|
||||
arg: '/'
|
||||
- type: 'disk_use_percent'
|
||||
arg: '/home'
|
||||
- type: 'disk_use'
|
||||
arg: '/home'
|
||||
- type: 'disk_free'
|
||||
arg: '/'
|
||||
- type: 'memory_use_percent'
|
||||
- type: 'memory_use'
|
||||
- type: 'memory_free'
|
||||
- type: 'processor_use'
|
||||
- type: 'process'
|
||||
arg: 'octave-cli'
|
||||
|
||||
sensor 2:
|
||||
platform: forecast
|
||||
api_key: <register on Forecast.io for your PRIVATE API>
|
||||
monitored_conditions:
|
||||
- summary
|
||||
- precip_type
|
||||
- precip_intensity
|
||||
- temperature
|
||||
- dew_point
|
||||
- wind_speed
|
||||
- wind_bearing
|
||||
- cloud_cover
|
||||
- humidity
|
||||
- pressure
|
||||
- visibility
|
||||
- ozone
|
||||
|
||||
script:
|
||||
# Turns on the bedroom lights and then the living room lights 1 minute later
|
||||
wakeup:
|
||||
alias: Wake Up
|
||||
sequence:
|
||||
# alias is optional
|
||||
- alias: Bedroom lights on
|
||||
execute_service: light.turn_on
|
||||
service_data:
|
||||
entity_id: group.bedroom
|
||||
- delay:
|
||||
# supports seconds, milliseconds, minutes, hours, etc.
|
||||
minutes: 1
|
||||
- alias: Living room lights on
|
||||
execute_service: light.turn_on
|
||||
service_data:
|
||||
entity_id: group.living_room
|
||||
|
||||
scene:
|
||||
- name: Romantic
|
||||
entities:
|
||||
light.tv_back_light: on
|
||||
light.ceiling:
|
||||
state: on
|
||||
xy_color: [0.33, 0.66]
|
||||
brightness: 200
|
||||
136
config/custom_components/example.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
custom_components.example
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Example component to target an entity_id to:
|
||||
- turn it on at 7AM in the morning
|
||||
- turn it on if anyone comes home and it is off
|
||||
- turn it off if all lights are turned off
|
||||
- turn it off if all people leave the house
|
||||
- offer a service to turn it on for 10 seconds
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Example custom component you will need to add the following to
|
||||
your configuration.yaml file.
|
||||
|
||||
example:
|
||||
target: TARGET_ENTITY
|
||||
|
||||
Variable:
|
||||
|
||||
target
|
||||
*Required
|
||||
TARGET_ENTITY should be one of your devices that can be turned on and off,
|
||||
ie a light or a switch. Example value could be light.Ceiling or switch.AC
|
||||
(if you have these devices with those names).
|
||||
"""
|
||||
import time
|
||||
import logging
|
||||
|
||||
from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_ON, STATE_OFF
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.helpers import validate_config
|
||||
import homeassistant.components as core
|
||||
|
||||
# The domain of your component. Should be equal to the name of your component
|
||||
DOMAIN = "example"
|
||||
|
||||
# List of component names (string) your component depends upon
|
||||
# We depend on group because group will be loaded after all the components that
|
||||
# initialize devices have been setup.
|
||||
DEPENDENCIES = ['group']
|
||||
|
||||
# Configuration key for the entity id we are targetting
|
||||
CONF_TARGET = 'target'
|
||||
|
||||
# Name of the service that we expose
|
||||
SERVICE_FLASH = 'flash'
|
||||
|
||||
# Shortcut for the logger
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup example component. """
|
||||
|
||||
# Validate that all required config options are given
|
||||
if not validate_config(config, {DOMAIN: [CONF_TARGET]}, _LOGGER):
|
||||
return False
|
||||
|
||||
target_id = config[DOMAIN][CONF_TARGET]
|
||||
|
||||
# Validate that the target entity id exists
|
||||
if hass.states.get(target_id) is None:
|
||||
_LOGGER.error("Target entity id %s does not exist", target_id)
|
||||
|
||||
# Tell the bootstrapper that we failed to initialize
|
||||
return False
|
||||
|
||||
# We will use the component helper methods to check the states.
|
||||
device_tracker = loader.get_component('device_tracker')
|
||||
light = loader.get_component('light')
|
||||
|
||||
def track_devices(entity_id, old_state, new_state):
|
||||
""" Called when the group.all devices change state. """
|
||||
|
||||
# If anyone comes home and the core is not on, turn it on.
|
||||
if new_state.state == STATE_HOME and not core.is_on(hass, target_id):
|
||||
|
||||
core.turn_on(hass, target_id)
|
||||
|
||||
# If all people leave the house and the core is on, turn it off
|
||||
elif new_state.state == STATE_NOT_HOME and core.is_on(hass, target_id):
|
||||
|
||||
core.turn_off(hass, target_id)
|
||||
|
||||
# Register our track_devices method to receive state changes of the
|
||||
# all tracked devices group.
|
||||
hass.states.track_change(
|
||||
device_tracker.ENTITY_ID_ALL_DEVICES, track_devices)
|
||||
|
||||
def wake_up(now):
|
||||
""" Turn it on in the morning if there are people home and
|
||||
it is not already on. """
|
||||
|
||||
if device_tracker.is_on(hass) and not core.is_on(hass, target_id):
|
||||
_LOGGER.info('People home at 7AM, turning it on')
|
||||
core.turn_on(hass, target_id)
|
||||
|
||||
# Register our wake_up service to be called at 7AM in the morning
|
||||
hass.track_time_change(wake_up, hour=7, minute=0, second=0)
|
||||
|
||||
def all_lights_off(entity_id, old_state, new_state):
|
||||
""" If all lights turn off, turn off. """
|
||||
|
||||
if core.is_on(hass, target_id):
|
||||
_LOGGER.info('All lights have been turned off, turning it off')
|
||||
core.turn_off(hass, target_id)
|
||||
|
||||
# Register our all_lights_off method to be called when all lights turn off
|
||||
hass.states.track_change(
|
||||
light.ENTITY_ID_ALL_LIGHTS, all_lights_off, STATE_ON, STATE_OFF)
|
||||
|
||||
def flash_service(call):
|
||||
""" Service that will turn the target off for 10 seconds
|
||||
if on and vice versa. """
|
||||
|
||||
if core.is_on(hass, target_id):
|
||||
core.turn_off(hass, target_id)
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
core.turn_on(hass, target_id)
|
||||
|
||||
else:
|
||||
core.turn_on(hass, target_id)
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
core.turn_off(hass, target_id)
|
||||
|
||||
# Register our service with HASS.
|
||||
hass.services.register(DOMAIN, SERVICE_FLASH, flash_service)
|
||||
|
||||
# Tells the bootstrapper that the component was successfully initialized
|
||||
return True
|
||||
28
config/custom_components/hello_world.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
custom_components.hello_world
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Implements the bare minimum that a component should implement.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the hello_word component you will need to add the following to your
|
||||
configuration.yaml file.
|
||||
|
||||
hello_world:
|
||||
"""
|
||||
|
||||
# The domain of your component. Should be equal to the name of your component
|
||||
DOMAIN = "hello_world"
|
||||
|
||||
# List of component names (string) your component depends upon
|
||||
DEPENDENCIES = []
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup our skeleton component. """
|
||||
|
||||
# States are in the format DOMAIN.OBJECT_ID
|
||||
hass.states.set('hello_world.Hello_World', 'Works!')
|
||||
|
||||
# return boolean to indicate that initialization was successful
|
||||
return True
|
||||
59
config/custom_components/mqtt_example.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
custom_components.mqtt_example
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Shows how to communicate with MQTT. Follows a topic on MQTT and updates the
|
||||
state of an entity to the last message received on that topic.
|
||||
|
||||
Also offers a service 'set_state' that will publish a message on the topic that
|
||||
will be passed via MQTT to our message received listener. Call the service with
|
||||
example payload {"new_state": "some new state"}.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the mqtt_example component you will need to add the following to your
|
||||
configuration.yaml file.
|
||||
|
||||
mqtt_example:
|
||||
topic: home-assistant/mqtt_example
|
||||
|
||||
"""
|
||||
import homeassistant.loader as loader
|
||||
|
||||
# The domain of your component. Should be equal to the name of your component
|
||||
DOMAIN = "mqtt_example"
|
||||
|
||||
# List of component names (string) your component depends upon
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
|
||||
CONF_TOPIC = 'topic'
|
||||
DEFAULT_TOPIC = 'home-assistant/mqtt_example'
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup our mqtt_example component. """
|
||||
mqtt = loader.get_component('mqtt')
|
||||
topic = config[DOMAIN].get('topic', DEFAULT_TOPIC)
|
||||
entity_id = 'mqtt_example.last_message'
|
||||
|
||||
# Listen to a message on MQTT
|
||||
|
||||
def message_received(topic, payload, qos):
|
||||
""" A new MQTT message has been received. """
|
||||
hass.states.set(entity_id, payload)
|
||||
|
||||
mqtt.subscribe(hass, topic, message_received)
|
||||
|
||||
hass.states.set(entity_id, 'No messages')
|
||||
|
||||
# Service to publish a message on MQTT
|
||||
|
||||
def set_state_service(call):
|
||||
""" Service to send a message. """
|
||||
mqtt.publish(hass, topic, call.data.get('new_state'))
|
||||
|
||||
# Register our service with Home Assistant
|
||||
hass.services.register(DOMAIN, 'set_state', set_state_service)
|
||||
|
||||
# return boolean to indicate that initialization was successful
|
||||
return True
|
||||
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 222 KiB |
BIN
docs/screenshots.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/states.png
|
Before Width: | Height: | Size: 160 KiB |
@@ -1,39 +0,0 @@
|
||||
[common]
|
||||
latitude=32.87336
|
||||
longitude=-117.22743
|
||||
|
||||
[httpinterface]
|
||||
api_password=mypass
|
||||
|
||||
[light.hue]
|
||||
host=192.168.1.2
|
||||
|
||||
[device_tracker.tomato]
|
||||
host=192.168.1.1
|
||||
username=admin
|
||||
password=PASSWORD
|
||||
http_id=aaaaaaaaaaaaaaa
|
||||
|
||||
[device_tracker.netgear]
|
||||
host=192.168.1.1
|
||||
username=admin
|
||||
password=PASSWORD
|
||||
|
||||
[chromecast]
|
||||
|
||||
[wemo]
|
||||
|
||||
[downloader]
|
||||
download_dir=downloads
|
||||
|
||||
[device_sun_light_trigger]
|
||||
# Example how you can specify a specific group that has to be turned on
|
||||
# light_group=group.living_room
|
||||
# Example how you can specify which light profile to use when turning lights on
|
||||
# light_profile=relax
|
||||
|
||||
# A comma seperated list of states that have to be tracked
|
||||
# As a single group
|
||||
[group]
|
||||
living_room=light.Bowl,light.Ceiling,light.TV_back_light
|
||||
bedroom=light.Bed_light
|
||||
@@ -1,577 +0,0 @@
|
||||
"""
|
||||
homeassistant
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Home Assistant is a Home Automation framework for observing the state
|
||||
of entities and react to changes.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import datetime as dt
|
||||
import functools as ft
|
||||
|
||||
import homeassistant.util as util
|
||||
|
||||
MATCH_ALL = '*'
|
||||
|
||||
DOMAIN = "homeassistant"
|
||||
|
||||
SERVICE_HOMEASSISTANT_STOP = "stop"
|
||||
|
||||
EVENT_HOMEASSISTANT_START = "homeassistant_start"
|
||||
EVENT_STATE_CHANGED = "state_changed"
|
||||
EVENT_TIME_CHANGED = "time_changed"
|
||||
|
||||
TIMER_INTERVAL = 10 # seconds
|
||||
|
||||
# We want to be able to fire every time a minute starts (seconds=0).
|
||||
# We want this so other modules can use that to make sure they fire
|
||||
# every minute.
|
||||
assert 60 % TIMER_INTERVAL == 0, "60 % TIMER_INTERVAL should be 0!"
|
||||
|
||||
BUS_NUM_THREAD = 4
|
||||
BUS_REPORT_BUSY_TIMEOUT = dt.timedelta(minutes=1)
|
||||
PRIO_SERVICE_DEFAULT = 1
|
||||
PRIO_EVENT_STATE = 2
|
||||
PRIO_EVENT_TIME = 3
|
||||
PRIO_EVENT_DEFAULT = 4
|
||||
|
||||
|
||||
def start_home_assistant(bus):
|
||||
""" Start home assistant. """
|
||||
request_shutdown = threading.Event()
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_HOMEASSISTANT_STOP,
|
||||
lambda service: request_shutdown.set())
|
||||
|
||||
Timer(bus)
|
||||
|
||||
bus.fire_event(EVENT_HOMEASSISTANT_START)
|
||||
|
||||
while not request_shutdown.isSet():
|
||||
try:
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
|
||||
def _process_match_param(parameter):
|
||||
""" Wraps parameter in a list if it is not one and returns it. """
|
||||
if not parameter:
|
||||
return MATCH_ALL
|
||||
elif isinstance(parameter, list):
|
||||
return parameter
|
||||
else:
|
||||
return [parameter]
|
||||
|
||||
|
||||
def _matcher(subject, pattern):
|
||||
""" Returns True if subject matches the pattern.
|
||||
|
||||
Pattern is either a list of allowed subjects or a `MATCH_ALL`.
|
||||
"""
|
||||
return MATCH_ALL == pattern or subject in pattern
|
||||
|
||||
|
||||
def track_state_change(bus, entity_id, action, from_state=None, to_state=None):
|
||||
""" Helper method to track specific state changes. """
|
||||
from_state = _process_match_param(from_state)
|
||||
to_state = _process_match_param(to_state)
|
||||
|
||||
@ft.wraps(action)
|
||||
def state_listener(event):
|
||||
""" State change listener that listens for specific state changes. """
|
||||
if entity_id == event.data['entity_id'] and \
|
||||
_matcher(event.data['old_state'].state, from_state) and \
|
||||
_matcher(event.data['new_state'].state, to_state):
|
||||
|
||||
action(event.data['entity_id'],
|
||||
event.data['old_state'],
|
||||
event.data['new_state'])
|
||||
|
||||
bus.listen_event(EVENT_STATE_CHANGED, state_listener)
|
||||
|
||||
|
||||
def track_point_in_time(bus, action, point_in_time):
|
||||
""" Adds a listener that will fire once after a spefic point in time. """
|
||||
|
||||
@ft.wraps(action)
|
||||
def point_in_time_listener(event):
|
||||
""" Listens for matching time_changed events. """
|
||||
now = event.data['now']
|
||||
|
||||
if now > point_in_time and not hasattr(point_in_time_listener, 'run'):
|
||||
|
||||
# Set variable so that we will never run twice.
|
||||
# Because the event bus might have to wait till a thread comes
|
||||
# available to execute this listener it might occur that the
|
||||
# listener gets lined up twice to be executed. This will make sure
|
||||
# the second time it does nothing.
|
||||
point_in_time_listener.run = True
|
||||
|
||||
bus.remove_event_listener(EVENT_TIME_CHANGED,
|
||||
point_in_time_listener)
|
||||
|
||||
action(now)
|
||||
|
||||
bus.listen_event(EVENT_TIME_CHANGED, point_in_time_listener)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def track_time_change(bus, action,
|
||||
year=None, month=None, day=None,
|
||||
hour=None, minute=None, second=None):
|
||||
""" Adds a listener that will fire if time matches a pattern. """
|
||||
|
||||
# We do not have to wrap the function with time pattern matching logic if
|
||||
# no pattern given
|
||||
if any((val is not None for val in
|
||||
(year, month, day, hour, minute, second))):
|
||||
|
||||
pmp = _process_match_param
|
||||
year, month, day = pmp(year), pmp(month), pmp(day)
|
||||
hour, minute, second = pmp(hour), pmp(minute), pmp(second)
|
||||
|
||||
@ft.wraps(action)
|
||||
def time_listener(event):
|
||||
""" Listens for matching time_changed events. """
|
||||
now = event.data['now']
|
||||
|
||||
mat = _matcher
|
||||
|
||||
if mat(now.year, year) and \
|
||||
mat(now.month, month) and \
|
||||
mat(now.day, day) and \
|
||||
mat(now.hour, hour) and \
|
||||
mat(now.minute, minute) and \
|
||||
mat(now.second, second):
|
||||
|
||||
action(now)
|
||||
|
||||
else:
|
||||
@ft.wraps(action)
|
||||
def time_listener(event):
|
||||
""" Fires every time event that comes in. """
|
||||
action(event.data['now'])
|
||||
|
||||
bus.listen_event(EVENT_TIME_CHANGED, time_listener)
|
||||
|
||||
|
||||
def create_bus_job_handler(logger):
|
||||
""" Creates a job handler that logs errors to supplied `logger`. """
|
||||
|
||||
def job_handler(job):
|
||||
""" Called whenever a job is available to do. """
|
||||
try:
|
||||
func, arg = job
|
||||
func(arg)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Catch any exception our service/event_listener might throw
|
||||
# We do not want to crash our ThreadPool
|
||||
logger.exception(u"BusHandler:Exception doing job")
|
||||
|
||||
return job_handler
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class ServiceCall(object):
|
||||
""" Represents a call to a service. """
|
||||
|
||||
__slots__ = ['domain', 'service', 'data']
|
||||
|
||||
def __init__(self, domain, service, data=None):
|
||||
self.domain = domain
|
||||
self.service = service
|
||||
self.data = data or {}
|
||||
|
||||
def __repr__(self):
|
||||
if self.data:
|
||||
return u"<ServiceCall {}.{}: {}>".format(
|
||||
self.domain, self.service, util.repr_helper(self.data))
|
||||
else:
|
||||
return u"<ServiceCall {}.{}>".format(self.domain, self.service)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class Event(object):
|
||||
""" Represents an event within the Bus. """
|
||||
|
||||
__slots__ = ['event_type', 'data']
|
||||
|
||||
def __init__(self, event_type, data=None):
|
||||
self.event_type = event_type
|
||||
self.data = data or {}
|
||||
|
||||
def __repr__(self):
|
||||
if self.data:
|
||||
return u"<Event {}: {}>".format(
|
||||
self.event_type, util.repr_helper(self.data))
|
||||
else:
|
||||
return u"<Event {}>".format(self.event_type)
|
||||
|
||||
|
||||
class Bus(object):
|
||||
""" Class that allows different components to communicate via services
|
||||
and events.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, thread_count=None):
|
||||
self.thread_count = thread_count or BUS_NUM_THREAD
|
||||
self._event_listeners = {}
|
||||
self._services = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.event_lock = threading.Lock()
|
||||
self.service_lock = threading.Lock()
|
||||
self.last_busy_notice = dt.datetime.now()
|
||||
|
||||
self.pool = util.ThreadPool(self.thread_count,
|
||||
create_bus_job_handler(self.logger))
|
||||
|
||||
@property
|
||||
def services(self):
|
||||
""" Dict with per domain a list of available services. """
|
||||
with self.service_lock:
|
||||
return {domain: self._services[domain].keys()
|
||||
for domain in self._services}
|
||||
|
||||
@property
|
||||
def event_listeners(self):
|
||||
""" Dict with events that is being listened for and the number
|
||||
of listeners.
|
||||
"""
|
||||
with self.event_lock:
|
||||
return {key: len(self._event_listeners[key])
|
||||
for key in self._event_listeners}
|
||||
|
||||
def has_service(self, domain, service):
|
||||
""" Returns True if specified service exists. """
|
||||
try:
|
||||
return service in self._services[domain]
|
||||
except KeyError: # if key 'domain' does not exist
|
||||
return False
|
||||
|
||||
def call_service(self, domain, service, service_data=None):
|
||||
""" Calls a service. """
|
||||
service_call = ServiceCall(domain, service, service_data)
|
||||
|
||||
with self.service_lock:
|
||||
try:
|
||||
self.pool.add_job(PRIO_SERVICE_DEFAULT,
|
||||
(self._services[domain][service],
|
||||
service_call))
|
||||
|
||||
self._check_busy()
|
||||
|
||||
except KeyError: # if key domain or service does not exist
|
||||
raise ServiceDoesNotExistError(
|
||||
u"Service does not exist: {}/{}".format(domain, service))
|
||||
|
||||
def register_service(self, domain, service, service_func):
|
||||
""" Register a service. """
|
||||
with self.service_lock:
|
||||
try:
|
||||
self._services[domain][service] = service_func
|
||||
|
||||
except KeyError: # Domain does not exist yet in self._services
|
||||
self._services[domain] = {service: service_func}
|
||||
|
||||
def fire_event(self, event_type, event_data=None):
|
||||
""" Fire an event. """
|
||||
with self.event_lock:
|
||||
# Copy the list of the current listeners because some listeners
|
||||
# remove themselves as a listener while being executed which
|
||||
# causes the iterator to be confused.
|
||||
get = self._event_listeners.get
|
||||
listeners = get(MATCH_ALL, []) + get(event_type, [])
|
||||
|
||||
event = Event(event_type, event_data)
|
||||
|
||||
self.logger.info(u"Bus:Handling {}".format(event))
|
||||
|
||||
if not listeners:
|
||||
return
|
||||
|
||||
if event_type == EVENT_TIME_CHANGED:
|
||||
prio = PRIO_EVENT_TIME
|
||||
elif event_type == EVENT_STATE_CHANGED:
|
||||
prio = PRIO_EVENT_STATE
|
||||
else:
|
||||
prio = PRIO_EVENT_DEFAULT
|
||||
|
||||
for func in listeners:
|
||||
self.pool.add_job(prio, (func, event))
|
||||
|
||||
self._check_busy()
|
||||
|
||||
def listen_event(self, event_type, listener):
|
||||
""" Listen for all events or events of a specific type.
|
||||
|
||||
To listen to all events specify the constant ``MATCH_ALL``
|
||||
as event_type.
|
||||
"""
|
||||
with self.event_lock:
|
||||
try:
|
||||
self._event_listeners[event_type].append(listener)
|
||||
|
||||
except KeyError: # event_type did not exist
|
||||
self._event_listeners[event_type] = [listener]
|
||||
|
||||
def listen_once_event(self, event_type, listener):
|
||||
""" Listen once for event of a specific type.
|
||||
|
||||
To listen to all events specify the constant ``MATCH_ALL``
|
||||
as event_type.
|
||||
|
||||
Note: at the moment it is impossible to remove a one time listener.
|
||||
"""
|
||||
@ft.wraps(listener)
|
||||
def onetime_listener(event):
|
||||
""" Removes listener from eventbus and then fires listener. """
|
||||
if not hasattr(onetime_listener, 'run'):
|
||||
# Set variable so that we will never run twice.
|
||||
# Because the event bus might have to wait till a thread comes
|
||||
# available to execute this listener it might occur that the
|
||||
# listener gets lined up twice to be executed.
|
||||
# This will make sure the second time it does nothing.
|
||||
onetime_listener.run = True
|
||||
|
||||
self.remove_event_listener(event_type, onetime_listener)
|
||||
|
||||
listener(event)
|
||||
|
||||
self.listen_event(event_type, onetime_listener)
|
||||
|
||||
def remove_event_listener(self, event_type, listener):
|
||||
""" Removes a listener of a specific event_type. """
|
||||
with self.event_lock:
|
||||
try:
|
||||
self._event_listeners[event_type].remove(listener)
|
||||
|
||||
# delete event_type list if empty
|
||||
if not self._event_listeners[event_type]:
|
||||
self._event_listeners.pop(event_type)
|
||||
|
||||
except (KeyError, AttributeError):
|
||||
# KeyError is key event_type listener did not exist
|
||||
# AttributeError if listener did not exist within event_type
|
||||
pass
|
||||
|
||||
def _check_busy(self):
|
||||
""" Complain if we have more than twice as many jobs queued as threads
|
||||
and if we didn't complain about it recently. """
|
||||
if self.pool.queue.qsize() / self.thread_count >= 2 and \
|
||||
dt.datetime.now()-self.last_busy_notice > BUS_REPORT_BUSY_TIMEOUT:
|
||||
|
||||
self.last_busy_notice = dt.datetime.now()
|
||||
|
||||
log_error = self.logger.error
|
||||
|
||||
log_error(
|
||||
u"Bus:All {} threads are busy and {} jobs pending".format(
|
||||
self.thread_count, self.pool.queue.qsize()))
|
||||
|
||||
jobs = self.pool.current_jobs
|
||||
|
||||
for start, job in jobs:
|
||||
log_error(u"Bus:Current job from {}: {}".format(
|
||||
util.datetime_to_str(start), job))
|
||||
|
||||
|
||||
class State(object):
|
||||
""" Object to represent a state within the state machine. """
|
||||
|
||||
__slots__ = ['entity_id', 'state', 'attributes', 'last_changed']
|
||||
|
||||
def __init__(self, entity_id, state, attributes=None, last_changed=None):
|
||||
self.entity_id = entity_id
|
||||
self.state = state
|
||||
self.attributes = attributes or {}
|
||||
last_changed = last_changed or dt.datetime.now()
|
||||
|
||||
# Strip microsecond from last_changed else we cannot guarantee
|
||||
# state == State.from_dict(state.as_dict())
|
||||
# This behavior occurs because to_dict uses datetime_to_str
|
||||
# which strips microseconds
|
||||
if last_changed.microsecond:
|
||||
self.last_changed = last_changed - dt.timedelta(
|
||||
microseconds=last_changed.microsecond)
|
||||
else:
|
||||
self.last_changed = last_changed
|
||||
|
||||
def copy(self):
|
||||
""" Creates a copy of itself. """
|
||||
return State(self.entity_id, self.state,
|
||||
dict(self.attributes), self.last_changed)
|
||||
|
||||
def as_dict(self):
|
||||
""" Converts State to a dict to be used within JSON.
|
||||
Ensures: state == State.from_dict(state.as_dict()) """
|
||||
|
||||
return {'entity_id': self.entity_id,
|
||||
'state': self.state,
|
||||
'attributes': self.attributes,
|
||||
'last_changed': util.datetime_to_str(self.last_changed)}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(json_dict):
|
||||
""" Static method to create a state from a dict.
|
||||
Ensures: state == State.from_json_dict(state.to_json_dict()) """
|
||||
|
||||
try:
|
||||
last_changed = json_dict.get('last_changed')
|
||||
|
||||
if last_changed:
|
||||
last_changed = util.str_to_datetime(last_changed)
|
||||
|
||||
return State(json_dict['entity_id'],
|
||||
json_dict['state'],
|
||||
json_dict.get('attributes'),
|
||||
last_changed)
|
||||
except KeyError: # if key 'entity_id' or 'state' did not exist
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
if self.attributes:
|
||||
return u"<state {}:{} @ {}>".format(
|
||||
self.state, util.repr_helper(self.attributes),
|
||||
util.datetime_to_str(self.last_changed))
|
||||
else:
|
||||
return u"<state {} @ {}>".format(
|
||||
self.state, util.datetime_to_str(self.last_changed))
|
||||
|
||||
|
||||
class StateMachine(object):
|
||||
""" Helper class that tracks the state of different entities. """
|
||||
|
||||
def __init__(self, bus):
|
||||
self.states = {}
|
||||
self.bus = bus
|
||||
self.lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def entity_ids(self):
|
||||
""" List of entitie ids that are being tracked. """
|
||||
with self.lock:
|
||||
return self.states.keys()
|
||||
|
||||
def remove_entity(self, entity_id):
|
||||
""" Removes a entity from the state machine.
|
||||
|
||||
Returns boolean to indicate if a entity was removed. """
|
||||
with self.lock:
|
||||
try:
|
||||
del self.states[entity_id]
|
||||
|
||||
return True
|
||||
|
||||
except KeyError:
|
||||
# if entity does not exist
|
||||
return False
|
||||
|
||||
def set_state(self, entity_id, new_state, attributes=None):
|
||||
""" Set the state of an entity, add entity if it does not exist.
|
||||
|
||||
Attributes is an optional dict to specify attributes of this state. """
|
||||
|
||||
attributes = attributes or {}
|
||||
|
||||
with self.lock:
|
||||
# Change state and fire listeners
|
||||
try:
|
||||
old_state = self.states[entity_id]
|
||||
|
||||
except KeyError:
|
||||
# If state did not exist yet
|
||||
self.states[entity_id] = State(entity_id, new_state,
|
||||
attributes)
|
||||
|
||||
else:
|
||||
if old_state.state != new_state or \
|
||||
old_state.attributes != attributes:
|
||||
|
||||
state = self.states[entity_id] = \
|
||||
State(entity_id, new_state, attributes)
|
||||
|
||||
self.bus.fire_event(EVENT_STATE_CHANGED,
|
||||
{'entity_id': entity_id,
|
||||
'old_state': old_state,
|
||||
'new_state': state})
|
||||
|
||||
def get_state(self, entity_id):
|
||||
""" Returns the state of the specified entity. """
|
||||
with self.lock:
|
||||
try:
|
||||
# Make a copy so people won't mutate the state
|
||||
return self.states[entity_id].copy()
|
||||
|
||||
except KeyError:
|
||||
# If entity does not exist
|
||||
return None
|
||||
|
||||
def is_state(self, entity_id, state):
|
||||
""" Returns True if entity exists and is specified state. """
|
||||
try:
|
||||
return self.get_state(entity_id).state == state
|
||||
except AttributeError:
|
||||
# get_state returned None
|
||||
return False
|
||||
|
||||
|
||||
class Timer(threading.Thread):
|
||||
""" Timer will sent out an event every TIMER_INTERVAL seconds. """
|
||||
|
||||
def __init__(self, bus):
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
self.daemon = True
|
||||
self.bus = bus
|
||||
|
||||
bus.listen_once_event(EVENT_HOMEASSISTANT_START,
|
||||
lambda event: self.start())
|
||||
|
||||
def run(self):
|
||||
""" Start the timer. """
|
||||
|
||||
logging.getLogger(__name__).info("Timer:starting")
|
||||
|
||||
last_fired_on_second = -1
|
||||
|
||||
calc_now = dt.datetime.now
|
||||
|
||||
while True:
|
||||
now = calc_now()
|
||||
|
||||
# First check checks if we are not on a second matching the
|
||||
# timer interval. Second check checks if we did not already fire
|
||||
# this interval.
|
||||
if now.second % TIMER_INTERVAL or \
|
||||
now.second == last_fired_on_second:
|
||||
|
||||
# Sleep till it is the next time that we have to fire an event.
|
||||
# Aim for halfway through the second that fits TIMER_INTERVAL.
|
||||
# If TIMER_INTERVAL is 10 fire at .5, 10.5, 20.5, etc seconds.
|
||||
# This will yield the best results because time.sleep() is not
|
||||
# 100% accurate because of non-realtime OS's
|
||||
slp_seconds = TIMER_INTERVAL - now.second % TIMER_INTERVAL + \
|
||||
.5 - now.microsecond/1000000.0
|
||||
|
||||
time.sleep(slp_seconds)
|
||||
|
||||
now = calc_now()
|
||||
|
||||
last_fired_on_second = now.second
|
||||
|
||||
self.bus.fire_event(EVENT_TIME_CHANGED,
|
||||
{'now': now})
|
||||
|
||||
|
||||
class HomeAssistantError(Exception):
|
||||
""" General Home Assistant exception occured. """
|
||||
|
||||
|
||||
class ServiceDoesNotExistError(HomeAssistantError):
|
||||
""" A service has been referenced that deos not exist. """
|
||||
|
||||
265
homeassistant/__main__.py
Normal file
@@ -0,0 +1,265 @@
|
||||
""" Starts home assistant. """
|
||||
from __future__ import print_function
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
from homeassistant import bootstrap
|
||||
import homeassistant.config as config_util
|
||||
from homeassistant.const import __version__, EVENT_HOMEASSISTANT_START
|
||||
|
||||
|
||||
def validate_python():
|
||||
""" Validate we're running the right Python version. """
|
||||
major, minor = sys.version_info[:2]
|
||||
|
||||
if major < 3 or (major == 3 and minor < 4):
|
||||
print("Home Assistant requires atleast Python 3.4")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_config_path(config_dir):
|
||||
""" Validates configuration directory. """
|
||||
|
||||
lib_dir = os.path.join(config_dir, 'lib')
|
||||
|
||||
# Test if configuration directory exists
|
||||
if not os.path.isdir(config_dir):
|
||||
if config_dir != config_util.get_default_config_dir():
|
||||
print(('Fatal Error: Specified configuration directory does '
|
||||
'not exist {} ').format(config_dir))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
os.mkdir(config_dir)
|
||||
except OSError:
|
||||
print(('Fatal Error: Unable to create default configuration '
|
||||
'directory {} ').format(config_dir))
|
||||
sys.exit(1)
|
||||
|
||||
# Test if library directory exists
|
||||
if not os.path.isdir(lib_dir):
|
||||
try:
|
||||
os.mkdir(lib_dir)
|
||||
except OSError:
|
||||
print(('Fatal Error: Unable to create library '
|
||||
'directory {} ').format(lib_dir))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_config_file(config_dir):
|
||||
""" Ensure configuration file exists. """
|
||||
config_path = config_util.ensure_config_exists(config_dir)
|
||||
|
||||
if config_path is None:
|
||||
print('Error getting configuration path')
|
||||
sys.exit(1)
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
def get_arguments():
|
||||
""" Get parsed passed in arguments. """
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Home Assistant: Observe, Control, Automate.")
|
||||
parser.add_argument('--version', action='version', version=__version__)
|
||||
parser.add_argument(
|
||||
'-c', '--config',
|
||||
metavar='path_to_config_dir',
|
||||
default=config_util.get_default_config_dir(),
|
||||
help="Directory that contains the Home Assistant configuration")
|
||||
parser.add_argument(
|
||||
'--demo-mode',
|
||||
action='store_true',
|
||||
help='Start Home Assistant in demo mode')
|
||||
parser.add_argument(
|
||||
'--open-ui',
|
||||
action='store_true',
|
||||
help='Open the webinterface in a browser')
|
||||
parser.add_argument(
|
||||
'--skip-pip',
|
||||
action='store_true',
|
||||
help='Skips pip install of required packages on startup')
|
||||
parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
action='store_true',
|
||||
help="Enable verbose logging to file.")
|
||||
parser.add_argument(
|
||||
'--pid-file',
|
||||
metavar='path_to_pid_file',
|
||||
default=None,
|
||||
help='Path to PID file useful for running as daemon')
|
||||
parser.add_argument(
|
||||
'--log-rotate-days',
|
||||
type=int,
|
||||
default=None,
|
||||
help='Enables daily log rotation and keeps up to the specified days')
|
||||
parser.add_argument(
|
||||
'--install-osx',
|
||||
action='store_true',
|
||||
help='Installs as a service on OS X and loads on boot.')
|
||||
parser.add_argument(
|
||||
'--uninstall-osx',
|
||||
action='store_true',
|
||||
help='Uninstalls from OS X.')
|
||||
parser.add_argument(
|
||||
'--restart-osx',
|
||||
action='store_true',
|
||||
help='Restarts on OS X.')
|
||||
if os.name != "nt":
|
||||
parser.add_argument(
|
||||
'--daemon',
|
||||
action='store_true',
|
||||
help='Run Home Assistant as daemon')
|
||||
|
||||
arguments = parser.parse_args()
|
||||
if os.name == "nt":
|
||||
arguments.daemon = False
|
||||
return arguments
|
||||
|
||||
|
||||
def daemonize():
|
||||
""" Move current process to daemon process """
|
||||
# create first fork
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
|
||||
# decouple fork
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# create second fork
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def check_pid(pid_file):
|
||||
""" Check that HA is not already running """
|
||||
# check pid file
|
||||
try:
|
||||
pid = int(open(pid_file, 'r').readline())
|
||||
except IOError:
|
||||
# PID File does not exist
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
# PID does not exist
|
||||
return
|
||||
print('Fatal Error: HomeAssistant is already running.')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def write_pid(pid_file):
|
||||
""" Create PID File """
|
||||
pid = os.getpid()
|
||||
try:
|
||||
open(pid_file, 'w').write(str(pid))
|
||||
except IOError:
|
||||
print('Fatal Error: Unable to write pid file {}'.format(pid_file))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def install_osx():
|
||||
""" Setup to run via launchd on OS X """
|
||||
with os.popen('which hass') as inp:
|
||||
hass_path = inp.read().strip()
|
||||
|
||||
with os.popen('whoami') as inp:
|
||||
user = inp.read().strip()
|
||||
|
||||
cwd = os.path.dirname(__file__)
|
||||
template_path = os.path.join(cwd, 'startup', 'launchd.plist')
|
||||
|
||||
with open(template_path, 'r', encoding='utf-8') as inp:
|
||||
plist = inp.read()
|
||||
|
||||
plist = plist.replace("$HASS_PATH$", hass_path)
|
||||
plist = plist.replace("$USER$", user)
|
||||
|
||||
path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist")
|
||||
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8') as outp:
|
||||
outp.write(plist)
|
||||
except IOError as err:
|
||||
print('Unable to write to ' + path, err)
|
||||
return
|
||||
|
||||
os.popen('launchctl load -w -F ' + path)
|
||||
|
||||
print("Home Assistant has been installed. \
|
||||
Open it here: http://localhost:8123")
|
||||
|
||||
|
||||
def uninstall_osx():
|
||||
""" Unload from launchd on OS X """
|
||||
path = os.path.expanduser("~/Library/LaunchAgents/org.homeassistant.plist")
|
||||
os.popen('launchctl unload ' + path)
|
||||
|
||||
print("Home Assistant has been uninstalled.")
|
||||
|
||||
|
||||
def main():
|
||||
""" Starts Home Assistant. """
|
||||
validate_python()
|
||||
|
||||
args = get_arguments()
|
||||
|
||||
config_dir = os.path.join(os.getcwd(), args.config)
|
||||
ensure_config_path(config_dir)
|
||||
|
||||
# os x launchd functions
|
||||
if args.install_osx:
|
||||
install_osx()
|
||||
return
|
||||
if args.uninstall_osx:
|
||||
uninstall_osx()
|
||||
return
|
||||
if args.restart_osx:
|
||||
uninstall_osx()
|
||||
install_osx()
|
||||
return
|
||||
|
||||
# daemon functions
|
||||
if args.pid_file:
|
||||
check_pid(args.pid_file)
|
||||
if args.daemon:
|
||||
daemonize()
|
||||
if args.pid_file:
|
||||
write_pid(args.pid_file)
|
||||
|
||||
if args.demo_mode:
|
||||
config = {
|
||||
'frontend': {},
|
||||
'demo': {}
|
||||
}
|
||||
hass = bootstrap.from_config_dict(
|
||||
config, config_dir=config_dir, daemon=args.daemon,
|
||||
verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days)
|
||||
else:
|
||||
config_file = ensure_config_file(config_dir)
|
||||
print('Config directory:', config_dir)
|
||||
hass = bootstrap.from_config_file(
|
||||
config_file, daemon=args.daemon, verbose=args.verbose,
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days)
|
||||
|
||||
if args.open_ui:
|
||||
def open_browser(event):
|
||||
""" Open the webinterface in a browser. """
|
||||
if hass.config.api is not None:
|
||||
import webbrowser
|
||||
webbrowser.open(hass.config.api.base_url)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, open_browser)
|
||||
|
||||
hass.start()
|
||||
hass.block_till_stopped()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,199 +1,361 @@
|
||||
"""
|
||||
homeassistant.bootstrap
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides methods to bootstrap a home assistant instance.
|
||||
|
||||
Each method will return a tuple (bus, statemachine).
|
||||
|
||||
After bootstrapping you can add your own components or
|
||||
start by calling homeassistant.start_home_assistant(bus)
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import ConfigParser
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import logging.handlers
|
||||
from collections import defaultdict
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.components as components
|
||||
import homeassistant.core as core
|
||||
import homeassistant.util.dt as date_util
|
||||
import homeassistant.util.package as pkg_util
|
||||
import homeassistant.util.location as loc_util
|
||||
import homeassistant.config as config_util
|
||||
import homeassistant.loader as loader
|
||||
import homeassistant.components as core_components
|
||||
import homeassistant.components.group as group
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.const import (
|
||||
EVENT_COMPONENT_LOADED, CONF_LATITUDE, CONF_LONGITUDE,
|
||||
CONF_TEMPERATURE_UNIT, CONF_NAME, CONF_TIME_ZONE, CONF_CUSTOMIZE,
|
||||
TEMP_CELCIUS, TEMP_FAHRENHEIT)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_COMPONENT = 'component'
|
||||
|
||||
PLATFORM_FORMAT = '{}.{}'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
|
||||
def from_config_file(config_path):
|
||||
""" Starts home assistant with all possible functionality
|
||||
based on a config file. """
|
||||
def setup_component(hass, domain, config=None):
|
||||
""" Setup a component and all its dependencies. """
|
||||
|
||||
# Setup the logging for home assistant.
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
if domain in hass.config.components:
|
||||
return True
|
||||
|
||||
# Log errors to a file
|
||||
err_handler = logging.FileHandler("home-assistant.log",
|
||||
mode='w', delay=True)
|
||||
err_handler.setLevel(logging.ERROR)
|
||||
err_handler.setFormatter(
|
||||
logging.Formatter('%(asctime)s %(name)s: %(message)s',
|
||||
datefmt='%H:%M %d-%m-%y'))
|
||||
logging.getLogger('').addHandler(err_handler)
|
||||
_ensure_loader_prepared(hass)
|
||||
|
||||
# Start the actual bootstrapping
|
||||
logger = logging.getLogger(__name__)
|
||||
if config is None:
|
||||
config = defaultdict(dict)
|
||||
|
||||
statusses = []
|
||||
components = loader.load_order_component(domain)
|
||||
|
||||
# Read config
|
||||
config = ConfigParser.SafeConfigParser()
|
||||
config.read(config_path)
|
||||
# OrderedSet is empty if component or dependencies could not be resolved
|
||||
if not components:
|
||||
return False
|
||||
|
||||
# Init core
|
||||
bus = ha.Bus()
|
||||
statemachine = ha.StateMachine(bus)
|
||||
for component in components:
|
||||
if not _setup_component(hass, component, config):
|
||||
return False
|
||||
|
||||
has_opt = config.has_option
|
||||
get_opt = config.get
|
||||
has_section = config.has_section
|
||||
add_status = lambda name, result: statusses.append((name, result))
|
||||
load_module = lambda module: importlib.import_module(
|
||||
'homeassistant.components.'+module)
|
||||
return True
|
||||
|
||||
def get_opt_safe(section, option, default=None):
|
||||
""" Failure proof option retriever. """
|
||||
try:
|
||||
return config.get(section, option)
|
||||
except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
|
||||
return default
|
||||
|
||||
# Device scanner
|
||||
dev_scan = None
|
||||
def _handle_requirements(hass, component, name):
|
||||
""" Installs requirements for component. """
|
||||
if hass.config.skip_pip or not hasattr(component, 'REQUIREMENTS'):
|
||||
return True
|
||||
|
||||
for req in component.REQUIREMENTS:
|
||||
if not pkg_util.install_package(req, target=hass.config.path('lib')):
|
||||
_LOGGER.error('Not initializing %s because could not install '
|
||||
'dependency %s', name, req)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _setup_component(hass, domain, config):
|
||||
""" Setup a component for Home Assistant. """
|
||||
if domain in hass.config.components:
|
||||
return True
|
||||
component = loader.get_component(domain)
|
||||
|
||||
missing_deps = [dep for dep in component.DEPENDENCIES
|
||||
if dep not in hass.config.components]
|
||||
|
||||
if missing_deps:
|
||||
_LOGGER.error(
|
||||
'Not initializing %s because not all dependencies loaded: %s',
|
||||
domain, ", ".join(missing_deps))
|
||||
return False
|
||||
|
||||
if not _handle_requirements(hass, component, domain):
|
||||
return False
|
||||
|
||||
try:
|
||||
# For the error message if not all option fields exist
|
||||
opt_fields = "host, username, password"
|
||||
if not component.setup(hass, config):
|
||||
_LOGGER.error('component %s failed to initialize', domain)
|
||||
return False
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception('Error during setup of component %s', domain)
|
||||
return False
|
||||
|
||||
if has_section('device_tracker.tomato'):
|
||||
device_tracker = load_module('device_tracker')
|
||||
hass.config.components.append(component.DOMAIN)
|
||||
|
||||
dev_scan_name = "Tomato"
|
||||
opt_fields += ", http_id"
|
||||
# Assumption: if a component does not depend on groups
|
||||
# it communicates with devices
|
||||
if group.DOMAIN not in component.DEPENDENCIES:
|
||||
hass.pool.add_worker()
|
||||
|
||||
dev_scan = device_tracker.TomatoDeviceScanner(
|
||||
get_opt('device_tracker.tomato', 'host'),
|
||||
get_opt('device_tracker.tomato', 'username'),
|
||||
get_opt('device_tracker.tomato', 'password'),
|
||||
get_opt('device_tracker.tomato', 'http_id'))
|
||||
hass.bus.fire(
|
||||
EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN})
|
||||
|
||||
elif has_section('device_tracker.netgear'):
|
||||
device_tracker = load_module('device_tracker')
|
||||
return True
|
||||
|
||||
dev_scan_name = "Netgear"
|
||||
|
||||
dev_scan = device_tracker.NetgearDeviceScanner(
|
||||
get_opt('device_tracker.netgear', 'host'),
|
||||
get_opt('device_tracker.netgear', 'username'),
|
||||
get_opt('device_tracker.netgear', 'password'))
|
||||
def prepare_setup_platform(hass, config, domain, platform_name):
|
||||
""" Loads a platform and makes sure dependencies are setup. """
|
||||
_ensure_loader_prepared(hass)
|
||||
|
||||
except ConfigParser.NoOptionError:
|
||||
# If one of the options didn't exist
|
||||
logger.exception(("Error initializing {}DeviceScanner, "
|
||||
"could not find one of the following config "
|
||||
"options: {}".format(dev_scan_name, opt_fields)))
|
||||
platform_path = PLATFORM_FORMAT.format(domain, platform_name)
|
||||
|
||||
add_status("Device Scanner - {}".format(dev_scan_name), False)
|
||||
platform = loader.get_component(platform_path)
|
||||
|
||||
if dev_scan:
|
||||
add_status("Device Scanner - {}".format(dev_scan_name),
|
||||
dev_scan.success_init)
|
||||
# Not found
|
||||
if platform is None:
|
||||
_LOGGER.error('Unable to find platform %s', platform_path)
|
||||
return None
|
||||
|
||||
if not dev_scan.success_init:
|
||||
dev_scan = None
|
||||
# Already loaded
|
||||
elif platform_path in hass.config.components:
|
||||
return platform
|
||||
|
||||
# Device Tracker
|
||||
if dev_scan:
|
||||
device_tracker.DeviceTracker(bus, statemachine, dev_scan)
|
||||
# Load dependencies
|
||||
if hasattr(platform, 'DEPENDENCIES'):
|
||||
for component in platform.DEPENDENCIES:
|
||||
if not setup_component(hass, component, config):
|
||||
_LOGGER.error(
|
||||
'Unable to prepare setup for platform %s because '
|
||||
'dependency %s could not be initialized', platform_path,
|
||||
component)
|
||||
return None
|
||||
|
||||
add_status("Device Tracker", True)
|
||||
if not _handle_requirements(hass, platform, platform_path):
|
||||
return None
|
||||
|
||||
# Sun tracker
|
||||
if has_opt("common", "latitude") and \
|
||||
has_opt("common", "longitude"):
|
||||
return platform
|
||||
|
||||
sun = load_module('sun')
|
||||
|
||||
add_status("Weather - Ephem",
|
||||
sun.setup(
|
||||
bus, statemachine,
|
||||
get_opt("common", "latitude"),
|
||||
get_opt("common", "longitude")))
|
||||
def mount_local_lib_path(config_dir):
|
||||
""" Add local library to Python Path """
|
||||
sys.path.insert(0, os.path.join(config_dir, 'lib'))
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-statements, too-many-arguments
|
||||
def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
|
||||
verbose=False, daemon=False, skip_pip=False,
|
||||
log_rotate_days=None):
|
||||
"""
|
||||
Tries to configure Home Assistant from a config dict.
|
||||
|
||||
Dynamically loads required components and its dependencies.
|
||||
"""
|
||||
if hass is None:
|
||||
hass = core.HomeAssistant()
|
||||
if config_dir is not None:
|
||||
config_dir = os.path.abspath(config_dir)
|
||||
hass.config.config_dir = config_dir
|
||||
mount_local_lib_path(config_dir)
|
||||
|
||||
process_ha_core_config(hass, config.get(core.DOMAIN, {}))
|
||||
|
||||
if enable_log:
|
||||
enable_logging(hass, verbose, daemon, log_rotate_days)
|
||||
|
||||
hass.config.skip_pip = skip_pip
|
||||
if skip_pip:
|
||||
_LOGGER.warning('Skipping pip installation of required modules. '
|
||||
'This may cause issues.')
|
||||
|
||||
_ensure_loader_prepared(hass)
|
||||
|
||||
# Make a copy because we are mutating it.
|
||||
# Convert it to defaultdict so components can always have config dict
|
||||
# Convert values to dictionaries if they are None
|
||||
config = defaultdict(
|
||||
dict, {key: value or {} for key, value in config.items()})
|
||||
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
components = set(key.split(' ')[0] for key in config.keys()
|
||||
if key != core.DOMAIN)
|
||||
|
||||
if not core_components.setup(hass, config):
|
||||
_LOGGER.error('Home Assistant core failed to initialize. '
|
||||
'Further initialization aborted.')
|
||||
|
||||
return hass
|
||||
|
||||
_LOGGER.info('Home Assistant core initialized')
|
||||
|
||||
# Setup the components
|
||||
for domain in loader.load_order_components(components):
|
||||
_setup_component(hass, domain, config)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
def from_config_file(config_path, hass=None, verbose=False, daemon=False,
|
||||
skip_pip=True, log_rotate_days=None):
|
||||
"""
|
||||
Reads the configuration file and tries to start all the required
|
||||
functionality. Will add functionality to 'hass' parameter if given,
|
||||
instantiates a new Home Assistant object if 'hass' is not given.
|
||||
"""
|
||||
if hass is None:
|
||||
hass = core.HomeAssistant()
|
||||
|
||||
# Set config dir to directory holding config file
|
||||
config_dir = os.path.abspath(os.path.dirname(config_path))
|
||||
hass.config.config_dir = config_dir
|
||||
mount_local_lib_path(config_dir)
|
||||
|
||||
enable_logging(hass, verbose, daemon, log_rotate_days)
|
||||
|
||||
config_dict = config_util.load_config_file(config_path)
|
||||
|
||||
return from_config_dict(config_dict, hass, enable_log=False,
|
||||
skip_pip=skip_pip)
|
||||
|
||||
|
||||
def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None):
|
||||
""" Setup the logging for home assistant. """
|
||||
if not daemon:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) "
|
||||
"[%(name)s] %(message)s%(reset)s")
|
||||
try:
|
||||
from colorlog import ColoredFormatter
|
||||
logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
|
||||
fmt,
|
||||
datefmt='%y-%m-%d %H:%M:%S',
|
||||
reset=True,
|
||||
log_colors={
|
||||
'DEBUG': 'cyan',
|
||||
'INFO': 'green',
|
||||
'WARNING': 'yellow',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'red',
|
||||
}
|
||||
))
|
||||
except ImportError:
|
||||
_LOGGER.warning(
|
||||
"Colorlog package not found, console coloring disabled")
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
err_log_path = hass.config.path('home-assistant.log')
|
||||
err_path_exists = os.path.isfile(err_log_path)
|
||||
|
||||
# Check if we can write to the error log if it exists or that
|
||||
# we can create files in the containing directory if not.
|
||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or \
|
||||
(not err_path_exists and os.access(hass.config.config_dir, os.W_OK)):
|
||||
|
||||
if log_rotate_days:
|
||||
err_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
err_log_path, when='midnight', backupCount=log_rotate_days)
|
||||
else:
|
||||
err_handler = logging.FileHandler(
|
||||
err_log_path, mode='w', delay=True)
|
||||
|
||||
err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||
err_handler.setFormatter(
|
||||
logging.Formatter('%(asctime)s %(name)s: %(message)s',
|
||||
datefmt='%y-%m-%d %H:%M:%S'))
|
||||
logger = logging.getLogger('')
|
||||
logger.addHandler(err_handler)
|
||||
logger.setLevel(logging.INFO) # this sets the minimum log level
|
||||
|
||||
else:
|
||||
sun = None
|
||||
_LOGGER.error(
|
||||
'Unable to setup error log %s (access denied)', err_log_path)
|
||||
|
||||
# Chromecast
|
||||
if has_section("chromecast"):
|
||||
chromecast = load_module('chromecast')
|
||||
|
||||
chromecast_started = chromecast.setup(bus, statemachine)
|
||||
def process_ha_core_config(hass, config):
|
||||
""" Processes the [homeassistant] section from the config. """
|
||||
hac = hass.config
|
||||
|
||||
add_status("Chromecast", chromecast_started)
|
||||
else:
|
||||
chromecast_started = False
|
||||
def set_time_zone(time_zone_str):
|
||||
""" Helper method to set time zone in HA. """
|
||||
if time_zone_str is None:
|
||||
return
|
||||
|
||||
# WeMo
|
||||
if has_section("wemo"):
|
||||
wemo = load_module('wemo')
|
||||
time_zone = date_util.get_time_zone(time_zone_str)
|
||||
|
||||
add_status("WeMo", wemo.setup(bus, statemachine))
|
||||
if time_zone:
|
||||
hac.time_zone = time_zone
|
||||
date_util.set_default_time_zone(time_zone)
|
||||
else:
|
||||
_LOGGER.error('Received invalid time zone %s', time_zone_str)
|
||||
|
||||
# Light control
|
||||
if has_section("light.hue"):
|
||||
light = load_module('light')
|
||||
for key, attr, typ in ((CONF_LATITUDE, 'latitude', float),
|
||||
(CONF_LONGITUDE, 'longitude', float),
|
||||
(CONF_NAME, 'location_name', str)):
|
||||
if key in config:
|
||||
try:
|
||||
setattr(hac, attr, typ(config[key]))
|
||||
except ValueError:
|
||||
_LOGGER.error('Received invalid %s value for %s: %s',
|
||||
typ.__name__, key, attr)
|
||||
|
||||
light_control = light.HueLightControl(get_opt_safe("hue", "host"))
|
||||
set_time_zone(config.get(CONF_TIME_ZONE))
|
||||
|
||||
add_status("Light - Hue", light_control.success_init)
|
||||
customize = config.get(CONF_CUSTOMIZE)
|
||||
|
||||
light.setup(bus, statemachine, light_control)
|
||||
else:
|
||||
light_control = None
|
||||
if isinstance(customize, dict):
|
||||
for entity_id, attrs in config.get(CONF_CUSTOMIZE, {}).items():
|
||||
if not isinstance(attrs, dict):
|
||||
continue
|
||||
Entity.overwrite_attribute(entity_id, attrs.keys(), attrs.values())
|
||||
|
||||
if has_opt("downloader", "download_dir"):
|
||||
downloader = load_module('downloader')
|
||||
if CONF_TEMPERATURE_UNIT in config:
|
||||
unit = config[CONF_TEMPERATURE_UNIT]
|
||||
|
||||
add_status("Downloader", downloader.setup(
|
||||
bus, get_opt("downloader", "download_dir")))
|
||||
if unit == 'C':
|
||||
hac.temperature_unit = TEMP_CELCIUS
|
||||
elif unit == 'F':
|
||||
hac.temperature_unit = TEMP_FAHRENHEIT
|
||||
|
||||
add_status("Core components", components.setup(bus, statemachine))
|
||||
# If we miss some of the needed values, auto detect them
|
||||
if None not in (
|
||||
hac.latitude, hac.longitude, hac.temperature_unit, hac.time_zone):
|
||||
return
|
||||
|
||||
if has_section('browser'):
|
||||
add_status("Browser", load_module('browser').setup(bus))
|
||||
_LOGGER.info('Auto detecting location and temperature unit')
|
||||
|
||||
if has_section('keyboard'):
|
||||
add_status("Keyboard", load_module('keyboard').setup(bus))
|
||||
info = loc_util.detect_location_info()
|
||||
|
||||
# Init HTTP interface
|
||||
if has_opt("httpinterface", "api_password"):
|
||||
httpinterface = load_module('httpinterface')
|
||||
if info is None:
|
||||
_LOGGER.error('Could not detect location information')
|
||||
return
|
||||
|
||||
httpinterface.HTTPInterface(
|
||||
bus, statemachine,
|
||||
get_opt("httpinterface", "api_password"))
|
||||
if hac.latitude is None and hac.longitude is None:
|
||||
hac.latitude = info.latitude
|
||||
hac.longitude = info.longitude
|
||||
|
||||
add_status("HTTPInterface", True)
|
||||
if hac.temperature_unit is None:
|
||||
if info.use_fahrenheit:
|
||||
hac.temperature_unit = TEMP_FAHRENHEIT
|
||||
else:
|
||||
hac.temperature_unit = TEMP_CELCIUS
|
||||
|
||||
# Init groups
|
||||
if has_section("group"):
|
||||
group = load_module('group')
|
||||
if hac.location_name is None:
|
||||
hac.location_name = info.city
|
||||
|
||||
for name, entity_ids in config.items("group"):
|
||||
add_status("Group - {}".format(name),
|
||||
group.setup(bus, statemachine, name,
|
||||
entity_ids.split(",")))
|
||||
if hac.time_zone is None:
|
||||
set_time_zone(info.time_zone)
|
||||
|
||||
# Light trigger
|
||||
if light_control and sun:
|
||||
device_sun_light_trigger = load_module('device_sun_light_trigger')
|
||||
|
||||
light_group = get_opt_safe("device_sun_light_trigger", "light_group")
|
||||
light_profile = get_opt_safe("device_sun_light_trigger",
|
||||
"light_profile")
|
||||
|
||||
add_status("Device Sun Light Trigger",
|
||||
device_sun_light_trigger.setup(bus, statemachine,
|
||||
light_group, light_profile))
|
||||
|
||||
for component, success_init in statusses:
|
||||
status = "initialized" if success_init else "Failed to initialize"
|
||||
|
||||
logger.info("{}: {}".format(component, status))
|
||||
|
||||
ha.start_home_assistant(bus)
|
||||
def _ensure_loader_prepared(hass):
|
||||
""" Ensure Home Assistant loader is prepared. """
|
||||
if not loader.PREPARED:
|
||||
loader.prepare(hass)
|
||||
|
||||
@@ -15,128 +15,73 @@ Each component should publish services only under its own domain.
|
||||
|
||||
"""
|
||||
import itertools as it
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.util as util
|
||||
from homeassistant.helpers import extract_entity_ids
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF)
|
||||
|
||||
# Contains one string or a list of strings, each being an entity id
|
||||
ATTR_ENTITY_ID = 'entity_id'
|
||||
|
||||
# String with a friendly name for the entity
|
||||
ATTR_FRIENDLY_NAME = "friendly_name"
|
||||
|
||||
STATE_ON = 'on'
|
||||
STATE_OFF = 'off'
|
||||
STATE_NOT_HOME = 'not_home'
|
||||
STATE_HOME = 'home'
|
||||
|
||||
SERVICE_TURN_ON = 'turn_on'
|
||||
SERVICE_TURN_OFF = 'turn_off'
|
||||
|
||||
SERVICE_VOLUME_UP = "volume_up"
|
||||
SERVICE_VOLUME_DOWN = "volume_down"
|
||||
SERVICE_VOLUME_MUTE = "volume_mute"
|
||||
SERVICE_MEDIA_PLAY_PAUSE = "media_play_pause"
|
||||
SERVICE_MEDIA_NEXT_TRACK = "media_next_track"
|
||||
SERVICE_MEDIA_PREV_TRACK = "media_prev_track"
|
||||
|
||||
_LOADED_COMP = {}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_component(component):
|
||||
""" Returns requested component. Imports it if necessary. """
|
||||
|
||||
comps = _LOADED_COMP
|
||||
|
||||
# See if we have the module locally cached, else import it
|
||||
try:
|
||||
return comps[component]
|
||||
|
||||
except KeyError:
|
||||
# If comps[component] does not exist, import module
|
||||
try:
|
||||
comps[component] = importlib.import_module(
|
||||
'homeassistant.components.'+component)
|
||||
|
||||
except ImportError:
|
||||
# If we got a bogus component the input will fail
|
||||
comps[component] = None
|
||||
|
||||
return comps[component]
|
||||
|
||||
|
||||
def is_on(statemachine, entity_id=None):
|
||||
def is_on(hass, entity_id=None):
|
||||
""" Loads up the module to call the is_on method.
|
||||
If there is no entity id given we will check all. """
|
||||
if entity_id:
|
||||
group = _get_component('group')
|
||||
group = get_component('group')
|
||||
|
||||
entity_ids = group.expand_entity_ids([entity_id])
|
||||
entity_ids = group.expand_entity_ids(hass, [entity_id])
|
||||
else:
|
||||
entity_ids = statemachine.entity_ids
|
||||
entity_ids = hass.states.entity_ids()
|
||||
|
||||
for entity_id in entity_ids:
|
||||
domain = util.split_entity_id(entity_id)[0]
|
||||
|
||||
module = _get_component(domain)
|
||||
module = get_component(domain)
|
||||
|
||||
try:
|
||||
if module.is_on(statemachine, entity_id):
|
||||
if module.is_on(hass, entity_id):
|
||||
return True
|
||||
|
||||
except AttributeError:
|
||||
# module is None or method is_on does not exist
|
||||
pass
|
||||
_LOGGER.exception("Failed to call %s.is_on for %s",
|
||||
module, entity_id)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def turn_on(bus, **service_data):
|
||||
def turn_on(hass, entity_id=None, **service_data):
|
||||
""" Turns specified entity on if possible. """
|
||||
bus.call_service(ha.DOMAIN, SERVICE_TURN_ON, service_data)
|
||||
if entity_id is not None:
|
||||
service_data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(ha.DOMAIN, SERVICE_TURN_ON, service_data)
|
||||
|
||||
|
||||
def turn_off(bus, **service_data):
|
||||
def turn_off(hass, entity_id=None, **service_data):
|
||||
""" Turns specified entity off. """
|
||||
bus.call_service(ha.DOMAIN, SERVICE_TURN_OFF, service_data)
|
||||
if entity_id is not None:
|
||||
service_data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(ha.DOMAIN, SERVICE_TURN_OFF, service_data)
|
||||
|
||||
|
||||
def extract_entity_ids(statemachine, service):
|
||||
"""
|
||||
Helper method to extract a list of entity ids from a service call.
|
||||
Will convert group entity ids to the entity ids it represents.
|
||||
"""
|
||||
entity_ids = []
|
||||
|
||||
if service.data and ATTR_ENTITY_ID in service.data:
|
||||
group = _get_component('group')
|
||||
|
||||
# Entity ID attr can be a list or a string
|
||||
service_ent_id = service.data[ATTR_ENTITY_ID]
|
||||
if isinstance(service_ent_id, list):
|
||||
ent_ids = service_ent_id
|
||||
else:
|
||||
ent_ids = [service_ent_id]
|
||||
|
||||
entity_ids.extend(
|
||||
ent_id for ent_id
|
||||
in group.expand_entity_ids(statemachine, ent_ids)
|
||||
if ent_id not in entity_ids)
|
||||
|
||||
return entity_ids
|
||||
|
||||
|
||||
def setup(bus, statemachine):
|
||||
def setup(hass, config):
|
||||
""" Setup general services related to homeassistant. """
|
||||
|
||||
def handle_turn_service(service):
|
||||
""" Method to handle calls to homeassistant.turn_on/off. """
|
||||
|
||||
entity_ids = extract_entity_ids(statemachine, service)
|
||||
entity_ids = extract_entity_ids(hass, service)
|
||||
|
||||
# Generic turn on/off method requires entity id
|
||||
if not entity_ids:
|
||||
_LOGGER.error(
|
||||
"homeassistant/%s cannot be called without entity_id",
|
||||
service.service)
|
||||
return
|
||||
|
||||
# Group entity_ids by domain. groupby requires sorted data.
|
||||
@@ -150,13 +95,9 @@ def setup(bus, statemachine):
|
||||
# ent_ids is a generator, convert it to a list.
|
||||
data[ATTR_ENTITY_ID] = list(ent_ids)
|
||||
|
||||
try:
|
||||
bus.call_service(domain, service.service, data)
|
||||
except ha.ServiceDoesNotExistError:
|
||||
# turn_on service does not exist
|
||||
pass
|
||||
hass.services.call(domain, service.service, data, True)
|
||||
|
||||
bus.register_service(ha.DOMAIN, SERVICE_TURN_OFF, handle_turn_service)
|
||||
bus.register_service(ha.DOMAIN, SERVICE_TURN_ON, handle_turn_service)
|
||||
hass.services.register(ha.DOMAIN, SERVICE_TURN_OFF, handle_turn_service)
|
||||
hass.services.register(ha.DOMAIN, SERVICE_TURN_ON, handle_turn_service)
|
||||
|
||||
return True
|
||||
|
||||
132
homeassistant/components/alarm_control_panel/__init__.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
homeassistant.components.alarm_control_panel
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Component to interface with a alarm control panel.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.components import verisure
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY)
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
DOMAIN = 'alarm_control_panel'
|
||||
DEPENDENCIES = []
|
||||
SCAN_INTERVAL = 30
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
# Maps discovered services to their platforms
|
||||
DISCOVERY_PLATFORMS = {
|
||||
verisure.DISCOVER_SENSORS: 'verisure'
|
||||
}
|
||||
|
||||
SERVICE_TO_METHOD = {
|
||||
SERVICE_ALARM_DISARM: 'alarm_disarm',
|
||||
SERVICE_ALARM_ARM_HOME: 'alarm_arm_home',
|
||||
SERVICE_ALARM_ARM_AWAY: 'alarm_arm_away',
|
||||
}
|
||||
|
||||
ATTR_CODE = 'code'
|
||||
ATTR_CODE_FORMAT = 'code_format'
|
||||
|
||||
ATTR_TO_PROPERTY = [
|
||||
ATTR_CODE,
|
||||
ATTR_CODE_FORMAT
|
||||
]
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Track states and offer events for sensors. """
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL,
|
||||
DISCOVERY_PLATFORMS)
|
||||
|
||||
component.setup(config)
|
||||
|
||||
def alarm_service_handler(service):
|
||||
""" Maps services to methods on Alarm. """
|
||||
target_alarms = component.extract_from_service(service)
|
||||
|
||||
if ATTR_CODE not in service.data:
|
||||
return
|
||||
|
||||
code = service.data[ATTR_CODE]
|
||||
|
||||
method = SERVICE_TO_METHOD[service.service]
|
||||
|
||||
for alarm in target_alarms:
|
||||
getattr(alarm, method)(code)
|
||||
|
||||
descriptions = load_yaml_config_file(
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
for service in SERVICE_TO_METHOD:
|
||||
hass.services.register(DOMAIN, service, alarm_service_handler,
|
||||
descriptions.get(service))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def alarm_disarm(hass, code, entity_id=None):
|
||||
""" Send the alarm the command for disarm. """
|
||||
data = {ATTR_CODE: code}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data)
|
||||
|
||||
|
||||
def alarm_arm_home(hass, code, entity_id=None):
|
||||
""" Send the alarm the command for arm home. """
|
||||
data = {ATTR_CODE: code}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data)
|
||||
|
||||
|
||||
def alarm_arm_away(hass, code, entity_id=None):
|
||||
""" Send the alarm the command for arm away. """
|
||||
data = {ATTR_CODE: code}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data)
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
class AlarmControlPanel(Entity):
|
||||
""" ABC for alarm control devices. """
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
""" regex for code format or None if no code is required. """
|
||||
return None
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
""" Send disarm command. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
""" Send arm home command. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
""" Send arm away command. """
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Return the state attributes. """
|
||||
state_attr = {
|
||||
ATTR_CODE_FORMAT: self.code_format,
|
||||
}
|
||||
return state_attr
|
||||
167
homeassistant/components/alarm_control_panel/mqtt.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
homeassistant.components.alarm_control_panel.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This platform enables the possibility to control a MQTT alarm.
|
||||
In this platform, 'state_topic' and 'command_topic' are required.
|
||||
The alarm will only change state after receiving the a new state
|
||||
from 'state_topic'. If these messages are published with RETAIN flag,
|
||||
the MQTT alarm will receive an instant state update after subscription
|
||||
and will start with correct state. Otherwise, the initial state will
|
||||
be 'unknown'.
|
||||
|
||||
Configuration:
|
||||
|
||||
alarm_control_panel:
|
||||
platform: mqtt
|
||||
name: "MQTT Alarm"
|
||||
state_topic: "home/alarm"
|
||||
command_topic: "home/alarm/set"
|
||||
qos: 0
|
||||
payload_disarm: "DISARM"
|
||||
payload_arm_home: "ARM_HOME"
|
||||
payload_arm_away: "ARM_AWAY"
|
||||
code: "mySecretCode"
|
||||
|
||||
Variables:
|
||||
|
||||
name
|
||||
*Optional
|
||||
The name of the alarm. Default is 'MQTT Alarm'.
|
||||
|
||||
state_topic
|
||||
*Required
|
||||
The MQTT topic subscribed to receive state updates.
|
||||
|
||||
command_topic
|
||||
*Required
|
||||
The MQTT topic to publish commands to change the alarm state.
|
||||
|
||||
qos
|
||||
*Optional
|
||||
The maximum QoS level of the state topic. Default is 0.
|
||||
This QoS will also be used to publishing messages.
|
||||
|
||||
payload_disarm
|
||||
*Optional
|
||||
The payload do disarm alarm. Default is "DISARM".
|
||||
|
||||
payload_arm_home
|
||||
*Optional
|
||||
The payload to set armed-home mode. Default is "ARM_HOME".
|
||||
|
||||
payload_arm_away
|
||||
*Optional
|
||||
The payload to set armed-away mode. Default is "ARM_AWAY".
|
||||
|
||||
code
|
||||
*Optional
|
||||
If defined, specifies a code to enable or disable the alarm in the frontend.
|
||||
"""
|
||||
import logging
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
|
||||
from homeassistant.const import (STATE_UNKNOWN)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "MQTT Alarm"
|
||||
DEFAULT_QOS = 0
|
||||
DEFAULT_PAYLOAD_DISARM = "DISARM"
|
||||
DEFAULT_PAYLOAD_ARM_HOME = "ARM_HOME"
|
||||
DEFAULT_PAYLOAD_ARM_AWAY = "ARM_AWAY"
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the MQTT platform. """
|
||||
|
||||
if config.get('state_topic') is None:
|
||||
_LOGGER.error("Missing required variable: state_topic")
|
||||
return False
|
||||
|
||||
if config.get('command_topic') is None:
|
||||
_LOGGER.error("Missing required variable: command_topic")
|
||||
return False
|
||||
|
||||
add_devices([MqttAlarm(
|
||||
hass,
|
||||
config.get('name', DEFAULT_NAME),
|
||||
config.get('state_topic'),
|
||||
config.get('command_topic'),
|
||||
config.get('qos', DEFAULT_QOS),
|
||||
config.get('payload_disarm', DEFAULT_PAYLOAD_DISARM),
|
||||
config.get('payload_arm_home', DEFAULT_PAYLOAD_ARM_HOME),
|
||||
config.get('payload_arm_away', DEFAULT_PAYLOAD_ARM_AWAY),
|
||||
config.get('code'))])
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-instance-attributes
|
||||
class MqttAlarm(alarm.AlarmControlPanel):
|
||||
""" represents a MQTT alarm status within home assistant. """
|
||||
|
||||
def __init__(self, hass, name, state_topic, command_topic, qos,
|
||||
payload_disarm, payload_arm_home, payload_arm_away, code):
|
||||
self._state = STATE_UNKNOWN
|
||||
self._hass = hass
|
||||
self._name = name
|
||||
self._state_topic = state_topic
|
||||
self._command_topic = command_topic
|
||||
self._qos = qos
|
||||
self._payload_disarm = payload_disarm
|
||||
self._payload_arm_home = payload_arm_home
|
||||
self._payload_arm_away = payload_arm_away
|
||||
self._code = code
|
||||
|
||||
def message_received(topic, payload, qos):
|
||||
""" A new MQTT message has been received. """
|
||||
self._state = payload
|
||||
self.update_ha_state()
|
||||
|
||||
mqtt.subscribe(hass, self._state_topic, message_received, self._qos)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" No polling needed """
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the device. """
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
""" One or more characters if code is defined """
|
||||
return None if self._code is None else '.+'
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
""" Send disarm command. """
|
||||
if code == str(self._code) or self.code_format is None:
|
||||
mqtt.publish(self.hass, self._command_topic,
|
||||
self._payload_disarm, self._qos)
|
||||
else:
|
||||
_LOGGER.warning("Wrong code entered while disarming!")
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
""" Send arm home command. """
|
||||
if code == str(self._code) or self.code_format is None:
|
||||
mqtt.publish(self.hass, self._command_topic,
|
||||
self._payload_arm_home, self._qos)
|
||||
else:
|
||||
_LOGGER.warning("Wrong code entered while arming home!")
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
""" Send arm away command. """
|
||||
if code == str(self._code) or self.code_format is None:
|
||||
mqtt.publish(self.hass, self._command_topic,
|
||||
self._payload_arm_away, self._qos)
|
||||
else:
|
||||
_LOGGER.warning("Wrong code entered while arming away!")
|
||||
93
homeassistant/components/alarm_control_panel/verisure.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
homeassistant.components.alarm_control_panel.verisure
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Interfaces with Verisure alarm control panel.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import homeassistant.components.verisure as verisure
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
|
||||
from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the Verisure platform. """
|
||||
|
||||
if not verisure.MY_PAGES:
|
||||
_LOGGER.error('A connection has not been made to Verisure mypages.')
|
||||
return False
|
||||
|
||||
alarms = []
|
||||
|
||||
alarms.extend([
|
||||
VerisureAlarm(value)
|
||||
for value in verisure.get_alarm_status().values()
|
||||
if verisure.SHOW_ALARM
|
||||
])
|
||||
|
||||
add_devices(alarms)
|
||||
|
||||
|
||||
class VerisureAlarm(alarm.AlarmControlPanel):
|
||||
""" Represents a Verisure alarm status. """
|
||||
|
||||
def __init__(self, alarm_status):
|
||||
self._id = alarm_status.id
|
||||
self._device = verisure.MY_PAGES.DEVICE_ALARM
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device. """
|
||||
return 'Alarm {}'.format(self._id)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the device. """
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
""" Four digit code required. """
|
||||
return '^\\d{4}$'
|
||||
|
||||
def update(self):
|
||||
""" Update alarm status """
|
||||
verisure.update()
|
||||
|
||||
if verisure.STATUS[self._device][self._id].status == 'unarmed':
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
elif verisure.STATUS[self._device][self._id].status == 'armedhome':
|
||||
self._state = STATE_ALARM_ARMED_HOME
|
||||
elif verisure.STATUS[self._device][self._id].status == 'armedaway':
|
||||
self._state = STATE_ALARM_ARMED_AWAY
|
||||
elif verisure.STATUS[self._device][self._id].status != 'pending':
|
||||
_LOGGER.error(
|
||||
'Unknown alarm state %s',
|
||||
verisure.STATUS[self._device][self._id].status)
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
""" Send disarm command. """
|
||||
verisure.MY_PAGES.set_alarm_status(
|
||||
code,
|
||||
verisure.MY_PAGES.ALARM_DISARMED)
|
||||
_LOGGER.warning('disarming')
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
""" Send arm home command. """
|
||||
verisure.MY_PAGES.set_alarm_status(
|
||||
code,
|
||||
verisure.MY_PAGES.ALARM_ARMED_HOME)
|
||||
_LOGGER.warning('arming home')
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
""" Send arm away command. """
|
||||
verisure.MY_PAGES.set_alarm_status(
|
||||
code,
|
||||
verisure.MY_PAGES.ALARM_ARMED_AWAY)
|
||||
_LOGGER.warning('arming away')
|
||||
351
homeassistant/components/api.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
homeassistant.components.api
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides a Rest API for Home Assistant.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
import threading
|
||||
import json
|
||||
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.helpers.state import TrackStates
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.const import (
|
||||
URL_API, URL_API_STATES, URL_API_EVENTS, URL_API_SERVICES, URL_API_STREAM,
|
||||
URL_API_EVENT_FORWARD, URL_API_STATES_ENTITY, URL_API_COMPONENTS,
|
||||
URL_API_CONFIG, URL_API_BOOTSTRAP,
|
||||
EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL,
|
||||
HTTP_OK, HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
|
||||
DOMAIN = 'api'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
STREAM_PING_PAYLOAD = "ping"
|
||||
STREAM_PING_INTERVAL = 50 # seconds
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Register the API with the HTTP interface. """
|
||||
|
||||
if 'http' not in hass.config.components:
|
||||
_LOGGER.error('Dependency http is not loaded')
|
||||
return False
|
||||
|
||||
# /api - for validation purposes
|
||||
hass.http.register_path('GET', URL_API, _handle_get_api)
|
||||
|
||||
# /api/stream
|
||||
hass.http.register_path('GET', URL_API_STREAM, _handle_get_api_stream)
|
||||
|
||||
# /api/config
|
||||
hass.http.register_path('GET', URL_API_CONFIG, _handle_get_api_config)
|
||||
|
||||
# /api/bootstrap
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_BOOTSTRAP, _handle_get_api_bootstrap)
|
||||
|
||||
# /states
|
||||
hass.http.register_path('GET', URL_API_STATES, _handle_get_api_states)
|
||||
hass.http.register_path(
|
||||
'GET', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_get_api_states_entity)
|
||||
hass.http.register_path(
|
||||
'POST', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_post_state_entity)
|
||||
hass.http.register_path(
|
||||
'PUT', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_post_state_entity)
|
||||
|
||||
# /events
|
||||
hass.http.register_path('GET', URL_API_EVENTS, _handle_get_api_events)
|
||||
hass.http.register_path(
|
||||
'POST', re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'),
|
||||
_handle_api_post_events_event)
|
||||
|
||||
# /services
|
||||
hass.http.register_path('GET', URL_API_SERVICES, _handle_get_api_services)
|
||||
hass.http.register_path(
|
||||
'POST',
|
||||
re.compile((r'/api/services/'
|
||||
r'(?P<domain>[a-zA-Z\._0-9]+)/'
|
||||
r'(?P<service>[a-zA-Z\._0-9]+)')),
|
||||
_handle_post_api_services_domain_service)
|
||||
|
||||
# /event_forwarding
|
||||
hass.http.register_path(
|
||||
'POST', URL_API_EVENT_FORWARD, _handle_post_api_event_forward)
|
||||
hass.http.register_path(
|
||||
'DELETE', URL_API_EVENT_FORWARD, _handle_delete_api_event_forward)
|
||||
|
||||
# /components
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_COMPONENTS, _handle_get_api_components)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_api(handler, path_match, data):
|
||||
""" Renders the debug interface. """
|
||||
handler.write_json_message("API running.")
|
||||
|
||||
|
||||
def _handle_get_api_stream(handler, path_match, data):
|
||||
""" Provide a streaming interface for the event bus. """
|
||||
gracefully_closed = False
|
||||
hass = handler.server.hass
|
||||
wfile = handler.wfile
|
||||
write_lock = threading.Lock()
|
||||
block = threading.Event()
|
||||
|
||||
restrict = data.get('restrict')
|
||||
if restrict:
|
||||
restrict = restrict.split(',')
|
||||
|
||||
def write_message(payload):
|
||||
""" Writes a message to the output. """
|
||||
with write_lock:
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
|
||||
try:
|
||||
wfile.write(msg.encode("UTF-8"))
|
||||
wfile.flush()
|
||||
except IOError:
|
||||
block.set()
|
||||
|
||||
def forward_events(event):
|
||||
""" Forwards events to the open request. """
|
||||
nonlocal gracefully_closed
|
||||
|
||||
if block.is_set() or event.event_type == EVENT_TIME_CHANGED or \
|
||||
restrict and event.event_type not in restrict:
|
||||
return
|
||||
elif event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
gracefully_closed = True
|
||||
block.set()
|
||||
return
|
||||
|
||||
write_message(json.dumps(event, cls=rem.JSONEncoder))
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header('Content-type', 'text/event-stream')
|
||||
handler.end_headers()
|
||||
|
||||
hass.bus.listen(MATCH_ALL, forward_events)
|
||||
|
||||
while True:
|
||||
write_message(STREAM_PING_PAYLOAD)
|
||||
|
||||
block.wait(STREAM_PING_INTERVAL)
|
||||
|
||||
if block.is_set():
|
||||
break
|
||||
|
||||
if not gracefully_closed:
|
||||
_LOGGER.info("Found broken event stream to %s, cleaning up",
|
||||
handler.client_address[0])
|
||||
|
||||
hass.bus.remove_listener(MATCH_ALL, forward_events)
|
||||
|
||||
|
||||
def _handle_get_api_config(handler, path_match, data):
|
||||
""" Returns the Home Assistant config. """
|
||||
handler.write_json(handler.server.hass.config.as_dict())
|
||||
|
||||
|
||||
def _handle_get_api_bootstrap(handler, path_match, data):
|
||||
""" Returns all data needed to bootstrap Home Assistant. """
|
||||
hass = handler.server.hass
|
||||
|
||||
handler.write_json({
|
||||
'config': hass.config.as_dict(),
|
||||
'states': hass.states.all(),
|
||||
'events': _events_json(hass),
|
||||
'services': _services_json(hass),
|
||||
})
|
||||
|
||||
|
||||
def _handle_get_api_states(handler, path_match, data):
|
||||
""" Returns a dict containing all entity ids and their state. """
|
||||
handler.write_json(handler.server.hass.states.all())
|
||||
|
||||
|
||||
def _handle_get_api_states_entity(handler, path_match, data):
|
||||
""" Returns the state of a specific entity. """
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
state = handler.server.hass.states.get(entity_id)
|
||||
|
||||
if state:
|
||||
handler.write_json(state)
|
||||
else:
|
||||
handler.write_json_message("State does not exist.", HTTP_NOT_FOUND)
|
||||
|
||||
|
||||
def _handle_post_state_entity(handler, path_match, data):
|
||||
""" Handles updating the state of an entity.
|
||||
|
||||
This handles the following paths:
|
||||
/api/states/<entity_id>
|
||||
"""
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
try:
|
||||
new_state = data['state']
|
||||
except KeyError:
|
||||
handler.write_json_message("state not specified", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
attributes = data['attributes'] if 'attributes' in data else None
|
||||
|
||||
is_new_state = handler.server.hass.states.get(entity_id) is None
|
||||
|
||||
# Write state
|
||||
handler.server.hass.states.set(entity_id, new_state, attributes)
|
||||
|
||||
state = handler.server.hass.states.get(entity_id)
|
||||
|
||||
status_code = HTTP_CREATED if is_new_state else HTTP_OK
|
||||
|
||||
handler.write_json(
|
||||
state.as_dict(),
|
||||
status_code=status_code,
|
||||
location=URL_API_STATES_ENTITY.format(entity_id))
|
||||
|
||||
|
||||
def _handle_get_api_events(handler, path_match, data):
|
||||
""" Handles getting overview of event listeners. """
|
||||
handler.write_json(_events_json(handler.server.hass))
|
||||
|
||||
|
||||
def _handle_api_post_events_event(handler, path_match, event_data):
|
||||
""" Handles firing of an event.
|
||||
|
||||
This handles the following paths:
|
||||
/api/events/<event_type>
|
||||
|
||||
Events from /api are threated as remote events.
|
||||
"""
|
||||
event_type = path_match.group('event_type')
|
||||
|
||||
if event_data is not None and not isinstance(event_data, dict):
|
||||
handler.write_json_message(
|
||||
"event_data should be an object", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
event_origin = ha.EventOrigin.remote
|
||||
|
||||
# Special case handling for event STATE_CHANGED
|
||||
# We will try to convert state dicts back to State objects
|
||||
if event_type == ha.EVENT_STATE_CHANGED and event_data:
|
||||
for key in ('old_state', 'new_state'):
|
||||
state = ha.State.from_dict(event_data.get(key))
|
||||
|
||||
if state:
|
||||
event_data[key] = state
|
||||
|
||||
handler.server.hass.bus.fire(event_type, event_data, event_origin)
|
||||
|
||||
handler.write_json_message("Event {} fired.".format(event_type))
|
||||
|
||||
|
||||
def _handle_get_api_services(handler, path_match, data):
|
||||
""" Handles getting overview of services. """
|
||||
handler.write_json(_services_json(handler.server.hass))
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_post_api_services_domain_service(handler, path_match, data):
|
||||
""" Handles calling a service.
|
||||
|
||||
This handles the following paths:
|
||||
/api/services/<domain>/<service>
|
||||
"""
|
||||
domain = path_match.group('domain')
|
||||
service = path_match.group('service')
|
||||
|
||||
with TrackStates(handler.server.hass) as changed_states:
|
||||
handler.server.hass.services.call(domain, service, data, True)
|
||||
|
||||
handler.write_json(changed_states)
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_post_api_event_forward(handler, path_match, data):
|
||||
""" Handles adding an event forwarding target. """
|
||||
|
||||
try:
|
||||
host = data['host']
|
||||
api_password = data['api_password']
|
||||
except KeyError:
|
||||
handler.write_json_message(
|
||||
"No host or api_password received.", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
try:
|
||||
port = int(data['port']) if 'port' in data else None
|
||||
except ValueError:
|
||||
handler.write_json_message(
|
||||
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
api = rem.API(host, api_password, port)
|
||||
|
||||
if not api.validate_api():
|
||||
handler.write_json_message(
|
||||
"Unable to validate API", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
if handler.server.event_forwarder is None:
|
||||
handler.server.event_forwarder = \
|
||||
rem.EventForwarder(handler.server.hass)
|
||||
|
||||
handler.server.event_forwarder.connect(api)
|
||||
|
||||
handler.write_json_message("Event forwarding setup.")
|
||||
|
||||
|
||||
def _handle_delete_api_event_forward(handler, path_match, data):
|
||||
""" Handles deleting an event forwarding target. """
|
||||
|
||||
try:
|
||||
host = data['host']
|
||||
except KeyError:
|
||||
handler.write_json_message("No host received.", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
try:
|
||||
port = int(data['port']) if 'port' in data else None
|
||||
except ValueError:
|
||||
handler.write_json_message(
|
||||
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
if handler.server.event_forwarder is not None:
|
||||
api = rem.API(host, None, port)
|
||||
|
||||
handler.server.event_forwarder.disconnect(api)
|
||||
|
||||
handler.write_json_message("Event forwarding cancelled.")
|
||||
|
||||
|
||||
def _handle_get_api_components(handler, path_match, data):
|
||||
""" Returns all the loaded components. """
|
||||
|
||||
handler.write_json(handler.server.hass.config.components)
|
||||
|
||||
|
||||
def _services_json(hass):
|
||||
""" Generate services data to JSONify. """
|
||||
return [{"domain": key, "services": value}
|
||||
for key, value in hass.services.services.items()]
|
||||
|
||||
|
||||
def _events_json(hass):
|
||||
""" Generate event data to JSONify. """
|
||||
return [{"event": key, "listener_count": value}
|
||||
for key, value in hass.bus.listeners.items()]
|
||||
143
homeassistant/components/arduino.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
components.arduino
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
Arduino component that connects to a directly attached Arduino board which
|
||||
runs with the Firmata firmware.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Arduino board you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
arduino:
|
||||
port: /dev/ttyACM0
|
||||
|
||||
Variables:
|
||||
|
||||
port
|
||||
*Required
|
||||
The port where is your board connected to your Home Assistant system.
|
||||
If you are using an original Arduino the port will be named ttyACM*. The exact
|
||||
number can be determined with 'ls /dev/ttyACM*' or check your 'dmesg'/
|
||||
'journalctl -f' output. Keep in mind that Arduino clones are often using a
|
||||
different name for the port (e.g. '/dev/ttyUSB*').
|
||||
|
||||
A word of caution: The Arduino is not storing states. This means that with
|
||||
every initialization the pins are set to off/low.
|
||||
"""
|
||||
import logging
|
||||
|
||||
try:
|
||||
from PyMata.pymata import PyMata
|
||||
except ImportError:
|
||||
PyMata = None
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.const import (EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
|
||||
DOMAIN = "arduino"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['PyMata==2.07a']
|
||||
BOARD = None
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup the Arduino component. """
|
||||
|
||||
global PyMata # pylint: disable=invalid-name
|
||||
if PyMata is None:
|
||||
from PyMata.pymata import PyMata as PyMata_
|
||||
PyMata = PyMata_
|
||||
|
||||
import serial
|
||||
|
||||
if not validate_config(config,
|
||||
{DOMAIN: ['port']},
|
||||
_LOGGER):
|
||||
return False
|
||||
|
||||
global BOARD
|
||||
try:
|
||||
BOARD = ArduinoBoard(config[DOMAIN]['port'])
|
||||
except (serial.serialutil.SerialException, FileNotFoundError):
|
||||
_LOGGER.exception("Your port is not accessible.")
|
||||
return False
|
||||
|
||||
if BOARD.get_firmata()[1] <= 2:
|
||||
_LOGGER.error("The StandardFirmata sketch should be 2.2 or newer.")
|
||||
return False
|
||||
|
||||
def stop_arduino(event):
|
||||
""" Stop the Arduino service. """
|
||||
BOARD.disconnect()
|
||||
|
||||
def start_arduino(event):
|
||||
""" Start the Arduino service. """
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_arduino)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_arduino)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ArduinoBoard(object):
|
||||
""" Represents an Arduino board. """
|
||||
|
||||
def __init__(self, port):
|
||||
self._port = port
|
||||
self._board = PyMata(self._port, verbose=False)
|
||||
|
||||
def set_mode(self, pin, direction, mode):
|
||||
""" Sets the mode and the direction of a given pin. """
|
||||
if mode == 'analog' and direction == 'in':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.INPUT,
|
||||
self._board.ANALOG)
|
||||
elif mode == 'analog' and direction == 'out':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.OUTPUT,
|
||||
self._board.ANALOG)
|
||||
elif mode == 'digital' and direction == 'in':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.OUTPUT,
|
||||
self._board.DIGITAL)
|
||||
elif mode == 'digital' and direction == 'out':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.OUTPUT,
|
||||
self._board.DIGITAL)
|
||||
elif mode == 'pwm':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.OUTPUT,
|
||||
self._board.PWM)
|
||||
|
||||
def get_analog_inputs(self):
|
||||
""" Get the values from the pins. """
|
||||
self._board.capability_query()
|
||||
return self._board.get_analog_response_table()
|
||||
|
||||
def set_digital_out_high(self, pin):
|
||||
""" Sets a given digital pin to high. """
|
||||
self._board.digital_write(pin, 1)
|
||||
|
||||
def set_digital_out_low(self, pin):
|
||||
""" Sets a given digital pin to low. """
|
||||
self._board.digital_write(pin, 0)
|
||||
|
||||
def get_digital_in(self, pin):
|
||||
""" Gets the value from a given digital pin. """
|
||||
self._board.digital_read(pin)
|
||||
|
||||
def get_analog_in(self, pin):
|
||||
""" Gets the value from a given analog pin. """
|
||||
self._board.analog_read(pin)
|
||||
|
||||
def get_firmata(self):
|
||||
""" Return the version of the Firmata firmware. """
|
||||
return self._board.get_firmata_version()
|
||||
|
||||
def disconnect(self):
|
||||
""" Disconnects the board and closes the serial connection. """
|
||||
self._board.reset()
|
||||
self._board.close()
|
||||
223
homeassistant/components/automation/__init__.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
homeassistant.components.automation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Allows to setup simple automation rules via the config file.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.bootstrap import prepare_setup_platform
|
||||
from homeassistant.util import split_entity_id
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM
|
||||
from homeassistant.components import logbook
|
||||
|
||||
DOMAIN = 'automation'
|
||||
|
||||
DEPENDENCIES = ['group']
|
||||
|
||||
CONF_ALIAS = 'alias'
|
||||
CONF_SERVICE = 'service'
|
||||
CONF_SERVICE_ENTITY_ID = 'entity_id'
|
||||
CONF_SERVICE_DATA = 'data'
|
||||
|
||||
CONF_CONDITION = 'condition'
|
||||
CONF_ACTION = 'action'
|
||||
CONF_TRIGGER = 'trigger'
|
||||
CONF_CONDITION_TYPE = 'condition_type'
|
||||
|
||||
CONDITION_USE_TRIGGER_VALUES = 'use_trigger_values'
|
||||
CONDITION_TYPE_AND = 'and'
|
||||
CONDITION_TYPE_OR = 'or'
|
||||
|
||||
DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Sets up automation. """
|
||||
config_key = DOMAIN
|
||||
found = 1
|
||||
|
||||
while config_key in config:
|
||||
# check for one block syntax
|
||||
if isinstance(config[config_key], dict):
|
||||
config_block = _migrate_old_config(config[config_key])
|
||||
name = config_block.get(CONF_ALIAS, config_key)
|
||||
_setup_automation(hass, config_block, name, config)
|
||||
|
||||
# check for multiple block syntax
|
||||
elif isinstance(config[config_key], list):
|
||||
for list_no, config_block in enumerate(config[config_key]):
|
||||
name = config_block.get(CONF_ALIAS,
|
||||
"{}, {}".format(config_key, list_no))
|
||||
_setup_automation(hass, config_block, name, config)
|
||||
|
||||
# any scalar value is incorrect
|
||||
else:
|
||||
_LOGGER.error('Error in config in section %s.', config_key)
|
||||
|
||||
found += 1
|
||||
config_key = "{} {}".format(DOMAIN, found)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _setup_automation(hass, config_block, name, config):
|
||||
""" Setup one instance of automation """
|
||||
|
||||
action = _get_action(hass, config_block.get(CONF_ACTION, {}), name)
|
||||
|
||||
if action is None:
|
||||
return False
|
||||
|
||||
if CONF_CONDITION in config_block or CONF_CONDITION_TYPE in config_block:
|
||||
action = _process_if(hass, config, config_block, action)
|
||||
|
||||
if action is None:
|
||||
return False
|
||||
|
||||
_process_trigger(hass, config, config_block.get(CONF_TRIGGER, []), name,
|
||||
action)
|
||||
return True
|
||||
|
||||
|
||||
def _get_action(hass, config, name):
|
||||
""" Return an action based on a config. """
|
||||
|
||||
if CONF_SERVICE not in config:
|
||||
_LOGGER.error('Error setting up %s, no action specified.', name)
|
||||
return None
|
||||
|
||||
def action():
|
||||
""" Action to be executed. """
|
||||
_LOGGER.info('Executing %s', name)
|
||||
logbook.log_entry(hass, name, 'has been triggered', DOMAIN)
|
||||
|
||||
domain, service = split_entity_id(config[CONF_SERVICE])
|
||||
service_data = config.get(CONF_SERVICE_DATA, {})
|
||||
|
||||
if not isinstance(service_data, dict):
|
||||
_LOGGER.error("%s should be a dictionary", CONF_SERVICE_DATA)
|
||||
service_data = {}
|
||||
|
||||
if CONF_SERVICE_ENTITY_ID in config:
|
||||
try:
|
||||
service_data[ATTR_ENTITY_ID] = \
|
||||
config[CONF_SERVICE_ENTITY_ID].split(",")
|
||||
except AttributeError:
|
||||
service_data[ATTR_ENTITY_ID] = \
|
||||
config[CONF_SERVICE_ENTITY_ID]
|
||||
|
||||
hass.services.call(domain, service, service_data)
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _migrate_old_config(config):
|
||||
""" Migrate old config to new. """
|
||||
if CONF_PLATFORM not in config:
|
||||
return config
|
||||
|
||||
_LOGGER.warning(
|
||||
'You are using an old configuration format. Please upgrade: '
|
||||
'https://home-assistant.io/components/automation.html')
|
||||
|
||||
new_conf = {
|
||||
CONF_TRIGGER: dict(config),
|
||||
CONF_CONDITION: config.get('if', []),
|
||||
CONF_ACTION: dict(config),
|
||||
}
|
||||
|
||||
for cat, key, new_key in (('trigger', 'mqtt_topic', 'topic'),
|
||||
('trigger', 'mqtt_payload', 'payload'),
|
||||
('trigger', 'state_entity_id', 'entity_id'),
|
||||
('trigger', 'state_before', 'before'),
|
||||
('trigger', 'state_after', 'after'),
|
||||
('trigger', 'state_to', 'to'),
|
||||
('trigger', 'state_from', 'from'),
|
||||
('trigger', 'state_hours', 'hours'),
|
||||
('trigger', 'state_minutes', 'minutes'),
|
||||
('trigger', 'state_seconds', 'seconds'),
|
||||
('action', 'execute_service', 'service'),
|
||||
('action', 'service_entity_id', 'entity_id'),
|
||||
('action', 'service_data', 'data')):
|
||||
if key in new_conf[cat]:
|
||||
new_conf[cat][new_key] = new_conf[cat].pop(key)
|
||||
|
||||
return new_conf
|
||||
|
||||
|
||||
def _process_if(hass, config, p_config, action):
|
||||
""" Processes if checks. """
|
||||
|
||||
cond_type = p_config.get(CONF_CONDITION_TYPE,
|
||||
DEFAULT_CONDITION_TYPE).lower()
|
||||
|
||||
if_configs = p_config.get(CONF_CONDITION)
|
||||
use_trigger = if_configs == CONDITION_USE_TRIGGER_VALUES
|
||||
|
||||
if use_trigger:
|
||||
if_configs = p_config[CONF_TRIGGER]
|
||||
|
||||
if isinstance(if_configs, dict):
|
||||
if_configs = [if_configs]
|
||||
|
||||
checks = []
|
||||
for if_config in if_configs:
|
||||
platform = _resolve_platform('if_action', hass, config,
|
||||
if_config.get(CONF_PLATFORM))
|
||||
if platform is None:
|
||||
continue
|
||||
|
||||
check = platform.if_action(hass, if_config)
|
||||
|
||||
# Invalid conditions are allowed if we base it on trigger
|
||||
if check is None and not use_trigger:
|
||||
return None
|
||||
|
||||
checks.append(check)
|
||||
|
||||
if cond_type == CONDITION_TYPE_AND:
|
||||
def if_action():
|
||||
""" AND all conditions. """
|
||||
if all(check() for check in checks):
|
||||
action()
|
||||
else:
|
||||
def if_action():
|
||||
""" OR all conditions. """
|
||||
if any(check() for check in checks):
|
||||
action()
|
||||
|
||||
return if_action
|
||||
|
||||
|
||||
def _process_trigger(hass, config, trigger_configs, name, action):
|
||||
""" Setup triggers. """
|
||||
if isinstance(trigger_configs, dict):
|
||||
trigger_configs = [trigger_configs]
|
||||
|
||||
for conf in trigger_configs:
|
||||
platform = _resolve_platform('trigger', hass, config,
|
||||
conf.get(CONF_PLATFORM))
|
||||
if platform is None:
|
||||
continue
|
||||
|
||||
if platform.trigger(hass, conf, action):
|
||||
_LOGGER.info("Initialized rule %s", name)
|
||||
else:
|
||||
_LOGGER.error("Error setting up rule %s", name)
|
||||
|
||||
|
||||
def _resolve_platform(method, hass, config, platform):
|
||||
""" Find automation platform. """
|
||||
if platform is None:
|
||||
return None
|
||||
platform = prepare_setup_platform(hass, config, DOMAIN, platform)
|
||||
|
||||
if platform is None or not hasattr(platform, method):
|
||||
_LOGGER.error("Unknown automation platform specified for %s: %s",
|
||||
method, platform)
|
||||
return None
|
||||
|
||||
return platform
|
||||
32
homeassistant/components/automation/event.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
homeassistant.components.automation.event
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers event listening automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
CONF_EVENT_TYPE = "event_type"
|
||||
CONF_EVENT_DATA = "event_data"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for events based on config. """
|
||||
event_type = config.get(CONF_EVENT_TYPE)
|
||||
|
||||
if event_type is None:
|
||||
_LOGGER.error("Missing configuration key %s", CONF_EVENT_TYPE)
|
||||
return False
|
||||
|
||||
event_data = config.get(CONF_EVENT_DATA)
|
||||
|
||||
def handle_event(event):
|
||||
""" Listens for events and calls the action when data matches. """
|
||||
if not event_data or all(val == event.data.get(key) for key, val
|
||||
in event_data.items()):
|
||||
action()
|
||||
|
||||
hass.bus.listen(event_type, handle_event)
|
||||
return True
|
||||
34
homeassistant/components/automation/mqtt.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
homeassistant.components.automation.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers MQTT listening automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
CONF_TOPIC = 'topic'
|
||||
CONF_PAYLOAD = 'payload'
|
||||
|
||||
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
topic = config.get(CONF_TOPIC)
|
||||
payload = config.get(CONF_PAYLOAD)
|
||||
|
||||
if topic is None:
|
||||
logging.getLogger(__name__).error(
|
||||
"Missing configuration key %s", CONF_TOPIC)
|
||||
return False
|
||||
|
||||
def mqtt_automation_listener(msg_topic, msg_payload, qos):
|
||||
""" Listens for MQTT messages. """
|
||||
if payload is None or payload == msg_payload:
|
||||
action()
|
||||
|
||||
mqtt.subscribe(hass, topic, mqtt_automation_listener)
|
||||
|
||||
return True
|
||||
91
homeassistant/components/automation/numeric_state.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
homeassistant.components.automation.numeric_state
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers numeric state listening automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
|
||||
|
||||
CONF_ENTITY_ID = "entity_id"
|
||||
CONF_BELOW = "below"
|
||||
CONF_ABOVE = "above"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
|
||||
if entity_id is None:
|
||||
_LOGGER.error("Missing configuration key %s", CONF_ENTITY_ID)
|
||||
return False
|
||||
|
||||
below = config.get(CONF_BELOW)
|
||||
above = config.get(CONF_ABOVE)
|
||||
|
||||
if below is None and above is None:
|
||||
_LOGGER.error("Missing configuration key."
|
||||
" One of %s or %s is required",
|
||||
CONF_BELOW, CONF_ABOVE)
|
||||
return False
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def state_automation_listener(entity, from_s, to_s):
|
||||
""" Listens for state changes and calls action. """
|
||||
|
||||
# Fire action if we go from outside range into range
|
||||
if _in_range(to_s.state, above, below) and \
|
||||
(from_s is None or not _in_range(from_s.state, above, below)):
|
||||
action()
|
||||
|
||||
track_state_change(
|
||||
hass, entity_id, state_automation_listener)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def if_action(hass, config):
|
||||
""" Wraps action method with state based condition. """
|
||||
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
|
||||
if entity_id is None:
|
||||
_LOGGER.error("Missing configuration key %s", CONF_ENTITY_ID)
|
||||
return None
|
||||
|
||||
below = config.get(CONF_BELOW)
|
||||
above = config.get(CONF_ABOVE)
|
||||
|
||||
if below is None and above is None:
|
||||
_LOGGER.error("Missing configuration key."
|
||||
" One of %s or %s is required",
|
||||
CONF_BELOW, CONF_ABOVE)
|
||||
return None
|
||||
|
||||
def if_numeric_state():
|
||||
""" Test numeric state condition. """
|
||||
state = hass.states.get(entity_id)
|
||||
return state is not None and _in_range(state.state, above, below)
|
||||
|
||||
return if_numeric_state
|
||||
|
||||
|
||||
def _in_range(value, range_start, range_end):
|
||||
""" Checks if value is inside the range """
|
||||
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
_LOGGER.warn("Missing value in numeric check")
|
||||
return False
|
||||
|
||||
if range_start is not None and range_end is not None:
|
||||
return float(range_start) <= value < float(range_end)
|
||||
elif range_end is not None:
|
||||
return value < float(range_end)
|
||||
else:
|
||||
return float(range_start) <= value
|
||||
58
homeassistant/components/automation/state.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
homeassistant.components.automation.state
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers state listening automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.const import MATCH_ALL
|
||||
|
||||
|
||||
CONF_ENTITY_ID = "entity_id"
|
||||
CONF_FROM = "from"
|
||||
CONF_TO = "to"
|
||||
CONF_STATE = "state"
|
||||
|
||||
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
|
||||
if entity_id is None:
|
||||
logging.getLogger(__name__).error(
|
||||
"Missing trigger configuration key %s", CONF_ENTITY_ID)
|
||||
return False
|
||||
|
||||
from_state = config.get(CONF_FROM, MATCH_ALL)
|
||||
to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL
|
||||
|
||||
def state_automation_listener(entity, from_s, to_s):
|
||||
""" Listens for state changes and calls action. """
|
||||
action()
|
||||
|
||||
track_state_change(
|
||||
hass, entity_id, state_automation_listener, from_state, to_state)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def if_action(hass, config):
|
||||
""" Wraps action method with state based condition. """
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
state = config.get(CONF_STATE)
|
||||
|
||||
if entity_id is None or state is None:
|
||||
logging.getLogger(__name__).error(
|
||||
"Missing if-condition configuration key %s or %s", CONF_ENTITY_ID,
|
||||
CONF_STATE)
|
||||
return None
|
||||
|
||||
state = str(state)
|
||||
|
||||
def if_state():
|
||||
""" Test if condition. """
|
||||
return hass.states.is_state(entity_id, state)
|
||||
|
||||
return if_state
|
||||
103
homeassistant/components/automation/sun.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
homeassistant.components.automation.sun
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers sun based automation rules.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components import sun
|
||||
from homeassistant.helpers.event import track_point_in_utc_time
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
DEPENDENCIES = ['sun']
|
||||
|
||||
CONF_OFFSET = 'offset'
|
||||
CONF_EVENT = 'event'
|
||||
|
||||
EVENT_SUNSET = 'sunset'
|
||||
EVENT_SUNRISE = 'sunrise'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for events based on config. """
|
||||
event = config.get(CONF_EVENT)
|
||||
|
||||
if event is None:
|
||||
_LOGGER.error("Missing configuration key %s", CONF_EVENT)
|
||||
return False
|
||||
|
||||
event = event.lower()
|
||||
if event not in (EVENT_SUNRISE, EVENT_SUNSET):
|
||||
_LOGGER.error("Invalid value for %s: %s", CONF_EVENT, event)
|
||||
return False
|
||||
|
||||
if CONF_OFFSET in config:
|
||||
raw_offset = config.get(CONF_OFFSET)
|
||||
|
||||
negative_offset = False
|
||||
if raw_offset.startswith('-'):
|
||||
negative_offset = True
|
||||
raw_offset = raw_offset[1:]
|
||||
|
||||
try:
|
||||
(hour, minute, second) = [int(x) for x in raw_offset.split(':')]
|
||||
except ValueError:
|
||||
_LOGGER.error('Could not parse offset %s', raw_offset)
|
||||
return False
|
||||
|
||||
offset = timedelta(hours=hour, minutes=minute, seconds=second)
|
||||
|
||||
if negative_offset:
|
||||
offset *= -1
|
||||
else:
|
||||
offset = timedelta(0)
|
||||
|
||||
# Do something to call action
|
||||
if event == EVENT_SUNRISE:
|
||||
trigger_sunrise(hass, action, offset)
|
||||
else:
|
||||
trigger_sunset(hass, action, offset)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def trigger_sunrise(hass, action, offset):
|
||||
""" Trigger action at next sun rise. """
|
||||
def next_rise():
|
||||
""" Returns next sunrise. """
|
||||
next_time = sun.next_rising_utc(hass) + offset
|
||||
|
||||
while next_time < dt_util.utcnow():
|
||||
next_time = next_time + timedelta(days=1)
|
||||
|
||||
return next_time
|
||||
|
||||
def sunrise_automation_listener(now):
|
||||
""" Called when it's time for action. """
|
||||
track_point_in_utc_time(hass, sunrise_automation_listener, next_rise())
|
||||
action()
|
||||
|
||||
track_point_in_utc_time(hass, sunrise_automation_listener, next_rise())
|
||||
|
||||
|
||||
def trigger_sunset(hass, action, offset):
|
||||
""" Trigger action at next sun set. """
|
||||
def next_set():
|
||||
""" Returns next sunrise. """
|
||||
next_time = sun.next_setting_utc(hass) + offset
|
||||
|
||||
while next_time < dt_util.utcnow():
|
||||
next_time = next_time + timedelta(days=1)
|
||||
|
||||
return next_time
|
||||
|
||||
def sunset_automation_listener(now):
|
||||
""" Called when it's time for action. """
|
||||
track_point_in_utc_time(hass, sunset_automation_listener, next_set())
|
||||
action()
|
||||
|
||||
track_point_in_utc_time(hass, sunset_automation_listener, next_set())
|
||||
105
homeassistant/components/automation/time.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
homeassistant.components.automation.time
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers time listening automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.util import convert
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers.event import track_time_change
|
||||
|
||||
CONF_HOURS = "hours"
|
||||
CONF_MINUTES = "minutes"
|
||||
CONF_SECONDS = "seconds"
|
||||
CONF_BEFORE = "before"
|
||||
CONF_AFTER = "after"
|
||||
CONF_WEEKDAY = "weekday"
|
||||
|
||||
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
if CONF_AFTER in config:
|
||||
after = dt_util.parse_time_str(config[CONF_AFTER])
|
||||
if after is None:
|
||||
_error_time(config[CONF_AFTER], CONF_AFTER)
|
||||
return False
|
||||
hours, minutes, seconds = after.hour, after.minute, after.second
|
||||
elif (CONF_HOURS in config or CONF_MINUTES in config
|
||||
or CONF_SECONDS in config):
|
||||
hours = convert(config.get(CONF_HOURS), int)
|
||||
minutes = convert(config.get(CONF_MINUTES), int)
|
||||
seconds = convert(config.get(CONF_SECONDS), int)
|
||||
else:
|
||||
_LOGGER.error('One of %s, %s, %s OR %s needs to be specified',
|
||||
CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AFTER)
|
||||
return False
|
||||
|
||||
def time_automation_listener(now):
|
||||
""" Listens for time changes and calls action. """
|
||||
action()
|
||||
|
||||
track_time_change(hass, time_automation_listener,
|
||||
hour=hours, minute=minutes, second=seconds)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def if_action(hass, config):
|
||||
""" Wraps action method with time based condition. """
|
||||
before = config.get(CONF_BEFORE)
|
||||
after = config.get(CONF_AFTER)
|
||||
weekday = config.get(CONF_WEEKDAY)
|
||||
|
||||
if before is None and after is None and weekday is None:
|
||||
logging.getLogger(__name__).error(
|
||||
"Missing if-condition configuration key %s, %s or %s",
|
||||
CONF_BEFORE, CONF_AFTER, CONF_WEEKDAY)
|
||||
return None
|
||||
|
||||
if before is not None:
|
||||
before = dt_util.parse_time_str(before)
|
||||
if before is None:
|
||||
_error_time(before, CONF_BEFORE)
|
||||
return None
|
||||
|
||||
if after is not None:
|
||||
after = dt_util.parse_time_str(after)
|
||||
if after is None:
|
||||
_error_time(after, CONF_AFTER)
|
||||
return None
|
||||
|
||||
def time_if():
|
||||
""" Validate time based if-condition """
|
||||
now = dt_util.now()
|
||||
if before is not None and now > now.replace(hour=before.hour,
|
||||
minute=before.minute):
|
||||
return False
|
||||
|
||||
if after is not None and now < now.replace(hour=after.hour,
|
||||
minute=after.minute):
|
||||
return False
|
||||
|
||||
if weekday is not None:
|
||||
now_weekday = WEEKDAYS[now.weekday()]
|
||||
|
||||
if isinstance(weekday, str) and weekday != now_weekday or \
|
||||
now_weekday not in weekday:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return time_if
|
||||
|
||||
|
||||
def _error_time(value, key):
|
||||
""" Helper method to print error. """
|
||||
_LOGGER.error(
|
||||
"Received invalid value for '%s': %s", key, value)
|
||||
if isinstance(value, int):
|
||||
_LOGGER.error('Make sure you wrap time values in quotes')
|
||||
85
homeassistant/components/automation/zone.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
homeassistant.components.automation.zone
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers zone automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components import zone
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.const import (
|
||||
ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL)
|
||||
|
||||
|
||||
CONF_ENTITY_ID = "entity_id"
|
||||
CONF_ZONE = "zone"
|
||||
CONF_EVENT = "event"
|
||||
EVENT_ENTER = "enter"
|
||||
EVENT_LEAVE = "leave"
|
||||
DEFAULT_EVENT = EVENT_ENTER
|
||||
|
||||
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
zone_entity_id = config.get(CONF_ZONE)
|
||||
|
||||
if entity_id is None or zone_entity_id is None:
|
||||
logging.getLogger(__name__).error(
|
||||
"Missing trigger configuration key %s or %s", CONF_ENTITY_ID,
|
||||
CONF_ZONE)
|
||||
return False
|
||||
|
||||
event = config.get(CONF_EVENT, DEFAULT_EVENT)
|
||||
|
||||
def zone_automation_listener(entity, from_s, to_s):
|
||||
""" Listens for state changes and calls action. """
|
||||
if from_s and None in (from_s.attributes.get(ATTR_LATITUDE),
|
||||
from_s.attributes.get(ATTR_LONGITUDE)) or \
|
||||
None in (to_s.attributes.get(ATTR_LATITUDE),
|
||||
to_s.attributes.get(ATTR_LONGITUDE)):
|
||||
return
|
||||
|
||||
from_match = _in_zone(hass, zone_entity_id, from_s) if from_s else None
|
||||
to_match = _in_zone(hass, zone_entity_id, to_s)
|
||||
|
||||
if event == EVENT_ENTER and not from_match and to_match or \
|
||||
event == EVENT_LEAVE and from_match and not to_match:
|
||||
action()
|
||||
|
||||
track_state_change(
|
||||
hass, entity_id, zone_automation_listener, MATCH_ALL, MATCH_ALL)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def if_action(hass, config):
|
||||
""" Wraps action method with zone based condition. """
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
zone_entity_id = config.get(CONF_ZONE)
|
||||
|
||||
if entity_id is None or zone_entity_id is None:
|
||||
logging.getLogger(__name__).error(
|
||||
"Missing condition configuration key %s or %s", CONF_ENTITY_ID,
|
||||
CONF_ZONE)
|
||||
return False
|
||||
|
||||
def if_in_zone():
|
||||
""" Test if condition. """
|
||||
return _in_zone(hass, zone_entity_id, hass.states.get(entity_id))
|
||||
|
||||
return if_in_zone
|
||||
|
||||
|
||||
def _in_zone(hass, zone_entity_id, state):
|
||||
""" Check if state is in zone. """
|
||||
if not state or None in (state.attributes.get(ATTR_LATITUDE),
|
||||
state.attributes.get(ATTR_LONGITUDE)):
|
||||
return False
|
||||
|
||||
zone_state = hass.states.get(zone_entity_id)
|
||||
return zone_state and zone.in_zone(
|
||||
zone_state, state.attributes.get(ATTR_LATITUDE),
|
||||
state.attributes.get(ATTR_LONGITUDE),
|
||||
state.attributes.get(ATTR_GPS_ACCURACY, 0))
|
||||
@@ -6,20 +6,21 @@ Provides functionality to launch a webbrowser on the host machine.
|
||||
"""
|
||||
|
||||
DOMAIN = "browser"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_BROWSE_URL = "browse_url"
|
||||
|
||||
|
||||
def setup(bus):
|
||||
def setup(hass, config):
|
||||
""" Listen for browse_url events and open
|
||||
the url in the default webbrowser. """
|
||||
|
||||
import webbrowser
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_BROWSE_URL,
|
||||
lambda service:
|
||||
webbrowser.open(
|
||||
service.data.get('url',
|
||||
'https://www.google.com')))
|
||||
hass.services.register(DOMAIN, SERVICE_BROWSE_URL,
|
||||
lambda service:
|
||||
webbrowser.open(
|
||||
service.data.get(
|
||||
'url', 'https://www.google.com')))
|
||||
|
||||
return True
|
||||
|
||||
229
homeassistant/components/camera/__init__.py
Normal file
@@ -0,0 +1,229 @@
|
||||
# pylint: disable=too-many-lines
|
||||
"""
|
||||
homeassistant.components.camera
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Component to interface with various cameras.
|
||||
|
||||
The following features are supported:
|
||||
- Returning recorded camera images and streams
|
||||
- Proxying image requests via HA for external access
|
||||
- Converting a still image url into a live video stream
|
||||
|
||||
Upcoming features
|
||||
- Recording
|
||||
- Snapshot
|
||||
- Motion Detection Recording(for supported cameras)
|
||||
- Automatic Configuration(for supported cameras)
|
||||
- Creation of child entities for supported functions
|
||||
- Collating motion event images passed via FTP into time based events
|
||||
- A service for calling camera functions
|
||||
- Camera movement(panning)
|
||||
- Zoom
|
||||
- Light/Nightvision toggling
|
||||
- Support for more devices
|
||||
- Expanded documentation
|
||||
"""
|
||||
import requests
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_PICTURE,
|
||||
HTTP_NOT_FOUND,
|
||||
ATTR_ENTITY_ID,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
|
||||
DOMAIN = 'camera'
|
||||
DEPENDENCIES = ['http']
|
||||
GROUP_NAME_ALL_CAMERAS = 'all_cameras'
|
||||
SCAN_INTERVAL = 30
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
SWITCH_ACTION_RECORD = 'record'
|
||||
SWITCH_ACTION_SNAPSHOT = 'snapshot'
|
||||
|
||||
SERVICE_CAMERA = 'camera_service'
|
||||
|
||||
STATE_RECORDING = 'recording'
|
||||
|
||||
DEFAULT_RECORDING_SECONDS = 30
|
||||
|
||||
# Maps discovered services to their platforms
|
||||
DISCOVERY_PLATFORMS = {}
|
||||
|
||||
FILE_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S-%f'
|
||||
DIR_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S'
|
||||
|
||||
REC_DIR_PREFIX = 'recording-'
|
||||
REC_IMG_PREFIX = 'recording_image-'
|
||||
|
||||
STATE_STREAMING = 'streaming'
|
||||
STATE_IDLE = 'idle'
|
||||
|
||||
CAMERA_PROXY_URL = '/api/camera_proxy_stream/{0}'
|
||||
CAMERA_STILL_URL = '/api/camera_proxy/{0}'
|
||||
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?time={1}'
|
||||
|
||||
MULTIPART_BOUNDARY = '--jpegboundary'
|
||||
MJPEG_START_HEADER = 'Content-type: {0}\r\n\r\n'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def setup(hass, config):
|
||||
""" Track states and offer events for sensors. """
|
||||
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL,
|
||||
DISCOVERY_PLATFORMS)
|
||||
|
||||
component.setup(config)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# CAMERA COMPONENT ENDPOINTS
|
||||
# -------------------------------------------------------------------------
|
||||
# The following defines the endpoints for serving images from the camera
|
||||
# via the HA http server. This is means that you can access images from
|
||||
# your camera outside of your LAN without the need for port forwards etc.
|
||||
|
||||
# Because the authentication header can't be added in image requests these
|
||||
# endpoints are secured with session based security.
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _proxy_camera_image(handler, path_match, data):
|
||||
""" Proxies the camera image via the HA server. """
|
||||
entity_id = path_match.group(ATTR_ENTITY_ID)
|
||||
|
||||
camera = None
|
||||
if entity_id in component.entities.keys():
|
||||
camera = component.entities[entity_id]
|
||||
|
||||
if camera:
|
||||
response = camera.camera_image()
|
||||
handler.wfile.write(response)
|
||||
else:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(r'/api/camera_proxy/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_proxy_camera_image)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _proxy_camera_mjpeg_stream(handler, path_match, data):
|
||||
""" Proxies the camera image as an mjpeg stream via the HA server.
|
||||
This function takes still images from the IP camera and turns them
|
||||
into an MJPEG stream. This means that HA can return a live video
|
||||
stream even with only a still image URL available.
|
||||
"""
|
||||
entity_id = path_match.group(ATTR_ENTITY_ID)
|
||||
|
||||
camera = None
|
||||
if entity_id in component.entities.keys():
|
||||
camera = component.entities[entity_id]
|
||||
|
||||
if not camera:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
handler.end_headers()
|
||||
return
|
||||
|
||||
try:
|
||||
camera.is_streaming = True
|
||||
camera.update_ha_state()
|
||||
|
||||
handler.request.sendall(bytes('HTTP/1.1 200 OK\r\n', 'utf-8'))
|
||||
handler.request.sendall(bytes(
|
||||
'Content-type: multipart/x-mixed-replace; \
|
||||
boundary=--jpgboundary\r\n\r\n', 'utf-8'))
|
||||
handler.request.sendall(bytes('--jpgboundary\r\n', 'utf-8'))
|
||||
|
||||
# MJPEG_START_HEADER.format()
|
||||
|
||||
while True:
|
||||
|
||||
img_bytes = camera.camera_image()
|
||||
|
||||
headers_str = '\r\n'.join((
|
||||
'Content-length: {}'.format(len(img_bytes)),
|
||||
'Content-type: image/jpeg',
|
||||
)) + '\r\n\r\n'
|
||||
|
||||
handler.request.sendall(
|
||||
bytes(headers_str, 'utf-8') +
|
||||
img_bytes +
|
||||
bytes('\r\n', 'utf-8'))
|
||||
|
||||
handler.request.sendall(
|
||||
bytes('--jpgboundary\r\n', 'utf-8'))
|
||||
|
||||
except (requests.RequestException, IOError):
|
||||
camera.is_streaming = False
|
||||
camera.update_ha_state()
|
||||
|
||||
camera.is_streaming = False
|
||||
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(
|
||||
r'/api/camera_proxy_stream/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_proxy_camera_mjpeg_stream)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Camera(Entity):
|
||||
""" The base class for camera components """
|
||||
|
||||
def __init__(self):
|
||||
self.is_streaming = False
|
||||
|
||||
@property
|
||||
# pylint: disable=no-self-use
|
||||
def is_recording(self):
|
||||
""" Returns true if the device is recording """
|
||||
return False
|
||||
|
||||
@property
|
||||
# pylint: disable=no-self-use
|
||||
def brand(self):
|
||||
""" Should return a string of the camera brand """
|
||||
return None
|
||||
|
||||
@property
|
||||
# pylint: disable=no-self-use
|
||||
def model(self):
|
||||
""" Returns string of camera model """
|
||||
return None
|
||||
|
||||
def camera_image(self):
|
||||
""" Return bytes of camera image """
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the entity. """
|
||||
if self.is_recording:
|
||||
return STATE_RECORDING
|
||||
elif self.is_streaming:
|
||||
return STATE_STREAMING
|
||||
else:
|
||||
return STATE_IDLE
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Returns optional state attributes. """
|
||||
attr = {
|
||||
ATTR_ENTITY_PICTURE: ENTITY_IMAGE_URL.format(
|
||||
self.entity_id, time.time()),
|
||||
}
|
||||
|
||||
if self.model:
|
||||
attr['model_name'] = self.model
|
||||
|
||||
if self.brand:
|
||||
attr['brand'] = self.brand
|
||||
|
||||
return attr
|
||||
105
homeassistant/components/camera/foscam.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
homeassistant.components.camera.foscam
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
This component provides basic support for Foscam IP cameras.
|
||||
|
||||
As part of the basic support the following features will be provided:
|
||||
-MJPEG video streaming
|
||||
|
||||
To use this component, add the following to your configuration.yaml file.
|
||||
|
||||
camera:
|
||||
platform: foscam
|
||||
name: Door Camera
|
||||
ip: 192.168.0.123
|
||||
port: 88
|
||||
username: YOUR_USERNAME
|
||||
password: YOUR_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
ip
|
||||
*Required
|
||||
The IP address of your Foscam device.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of a visitor or operator of your camera. Oddly admin accounts
|
||||
don't seem to have access to take snapshots.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for accessing your camera.
|
||||
|
||||
name
|
||||
*Optional
|
||||
This parameter allows you to override the name of your camera in homeassistant.
|
||||
|
||||
port
|
||||
*Optional
|
||||
The port that the camera is running on. The default is 88.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.foscam.html
|
||||
"""
|
||||
import logging
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.camera import DOMAIN
|
||||
from homeassistant.components.camera import Camera
|
||||
import requests
|
||||
import re
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Adds a Foscam IP Camera. """
|
||||
if not validate_config({DOMAIN: config},
|
||||
{DOMAIN: ['username', 'password', 'ip']}, _LOGGER):
|
||||
return None
|
||||
|
||||
add_devices_callback([FoscamCamera(config)])
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class FoscamCamera(Camera):
|
||||
""" An implementation of a Foscam IP camera. """
|
||||
|
||||
def __init__(self, device_info):
|
||||
super(FoscamCamera, self).__init__()
|
||||
|
||||
ip_address = device_info.get('ip')
|
||||
port = device_info.get('port', 88)
|
||||
|
||||
self._base_url = 'http://' + ip_address + ':' + str(port) + '/'
|
||||
self._username = device_info.get('username')
|
||||
self._password = device_info.get('password')
|
||||
self._snap_picture_url = self._base_url \
|
||||
+ 'cgi-bin/CGIProxy.fcgi?cmd=snapPicture&usr=' \
|
||||
+ self._username + '&pwd=' + self._password
|
||||
self._name = device_info.get('name', 'Foscam Camera')
|
||||
|
||||
_LOGGER.info('Using the following URL for %s: %s',
|
||||
self._name, self._snap_picture_url)
|
||||
|
||||
def camera_image(self):
|
||||
""" Return a still image reponse from the camera. """
|
||||
|
||||
# send the request to snap a picture
|
||||
response = requests.get(self._snap_picture_url)
|
||||
|
||||
# parse the response to find the image file name
|
||||
|
||||
pattern = re.compile('src="[.][.]/(.*[.]jpg)"')
|
||||
filename = pattern.search(response.content.decode("utf-8")).group(1)
|
||||
|
||||
# send request for the image
|
||||
response = requests.get(self._base_url + filename)
|
||||
|
||||
return response.content
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Return the name of this device. """
|
||||
return self._name
|
||||
91
homeassistant/components/camera/generic.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
homeassistant.components.camera.generic
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for IP Cameras.
|
||||
|
||||
This component provides basic support for IP cameras. For the basic support to
|
||||
work you camera must support accessing a JPEG snapshot via a URL and you will
|
||||
need to specify the "still_image_url" parameter which should be the location of
|
||||
the JPEG image.
|
||||
|
||||
As part of the basic support the following features will be provided:
|
||||
- MJPEG video streaming
|
||||
- Saving a snapshot
|
||||
- Recording(JPEG frame capture)
|
||||
|
||||
To use this component, add the following to your configuration.yaml file.
|
||||
|
||||
camera:
|
||||
platform: generic
|
||||
name: Door Camera
|
||||
username: YOUR_USERNAME
|
||||
password: YOUR_PASSWORD
|
||||
still_image_url: http://YOUR_CAMERA_IP_AND_PORT/image.jpg
|
||||
|
||||
Variables:
|
||||
|
||||
still_image_url
|
||||
*Required
|
||||
The URL your camera serves the image on, eg. http://192.168.1.21:2112/
|
||||
|
||||
name
|
||||
*Optional
|
||||
This parameter allows you to override the name of your camera in Home
|
||||
Assistant.
|
||||
|
||||
username
|
||||
*Optional
|
||||
The username for accessing your camera.
|
||||
|
||||
password
|
||||
*Optional
|
||||
The password for accessing your camera.
|
||||
"""
|
||||
import logging
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.camera import DOMAIN
|
||||
from homeassistant.components.camera import Camera
|
||||
import requests
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Adds a generic IP Camera. """
|
||||
if not validate_config({DOMAIN: config}, {DOMAIN: ['still_image_url']},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
add_devices_callback([GenericCamera(config)])
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class GenericCamera(Camera):
|
||||
"""
|
||||
A generic implementation of an IP camera that is reachable over a URL.
|
||||
"""
|
||||
|
||||
def __init__(self, device_info):
|
||||
super().__init__()
|
||||
self._name = device_info.get('name', 'Generic Camera')
|
||||
self._username = device_info.get('username')
|
||||
self._password = device_info.get('password')
|
||||
self._still_image_url = device_info['still_image_url']
|
||||
|
||||
def camera_image(self):
|
||||
""" Return a still image reponse from the camera. """
|
||||
if self._username and self._password:
|
||||
response = requests.get(
|
||||
self._still_image_url,
|
||||
auth=HTTPBasicAuth(self._username, self._password))
|
||||
else:
|
||||
response = requests.get(self._still_image_url)
|
||||
|
||||
return response.content
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Return the name of this device. """
|
||||
return self._name
|
||||
@@ -1,275 +0,0 @@
|
||||
"""
|
||||
homeassistant.components.chromecast
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to interact with Chromecasts.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.util as util
|
||||
import homeassistant.components as components
|
||||
|
||||
DOMAIN = 'chromecast'
|
||||
|
||||
SERVICE_YOUTUBE_VIDEO = 'play_youtube_video'
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
STATE_NO_APP = 'no_app'
|
||||
|
||||
ATTR_HOST = 'host'
|
||||
ATTR_STATE = 'state'
|
||||
ATTR_OPTIONS = 'options'
|
||||
ATTR_MEDIA_STATE = 'media_state'
|
||||
ATTR_MEDIA_CONTENT_ID = 'media_content_id'
|
||||
ATTR_MEDIA_TITLE = 'media_title'
|
||||
ATTR_MEDIA_ARTIST = 'media_artist'
|
||||
ATTR_MEDIA_ALBUM = 'media_album'
|
||||
ATTR_MEDIA_IMAGE_URL = 'media_image_url'
|
||||
ATTR_MEDIA_VOLUME = 'media_volume'
|
||||
ATTR_MEDIA_DURATION = 'media_duration'
|
||||
|
||||
MEDIA_STATE_UNKNOWN = 'unknown'
|
||||
MEDIA_STATE_PLAYING = 'playing'
|
||||
MEDIA_STATE_STOPPED = 'stopped'
|
||||
|
||||
|
||||
def is_on(statemachine, entity_id=None):
|
||||
""" Returns true if specified ChromeCast entity_id is on.
|
||||
Will check all chromecasts if no entity_id specified. """
|
||||
|
||||
entity_ids = [entity_id] if entity_id \
|
||||
else util.filter_entity_ids(statemachine.entity_ids, DOMAIN)
|
||||
|
||||
return any(not statemachine.is_state(entity_id, STATE_NO_APP)
|
||||
for entity_id in entity_ids)
|
||||
|
||||
|
||||
def turn_off(bus, entity_id=None):
|
||||
""" Will turn off specified Chromecast or all. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_TURN_OFF, data)
|
||||
|
||||
|
||||
def volume_up(bus, entity_id=None):
|
||||
""" Send the chromecast the command for volume up. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_UP, data)
|
||||
|
||||
|
||||
def volume_down(bus, entity_id=None):
|
||||
""" Send the chromecast the command for volume down. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_DOWN, data)
|
||||
|
||||
|
||||
def media_play_pause(bus, entity_id=None):
|
||||
""" Send the chromecast the command for play/pause. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_PLAY_PAUSE, data)
|
||||
|
||||
|
||||
def media_next_track(bus, entity_id=None):
|
||||
""" Send the chromecast the command for next track. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_NEXT_TRACK, data)
|
||||
|
||||
|
||||
def media_prev_track(bus, entity_id=None):
|
||||
""" Send the chromecast the command for prev track. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_PREV_TRACK, data)
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals, too-many-branches
|
||||
def setup(bus, statemachine):
|
||||
""" Listen for chromecast events. """
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import pychromecast
|
||||
except ImportError:
|
||||
logger.exception(("Failed to import pychromecast. "
|
||||
"Did you maybe not install the 'pychromecast' "
|
||||
"dependency?"))
|
||||
|
||||
return False
|
||||
|
||||
logger.info("Scanning for Chromecasts")
|
||||
hosts = pychromecast.discover_chromecasts()
|
||||
|
||||
casts = {}
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
cast = pychromecast.PyChromecast(host)
|
||||
|
||||
entity_id = util.ensure_unique_string(
|
||||
ENTITY_ID_FORMAT.format(
|
||||
util.slugify(cast.device.friendly_name)),
|
||||
casts.keys())
|
||||
|
||||
casts[entity_id] = cast
|
||||
|
||||
except pychromecast.ConnectionError:
|
||||
pass
|
||||
|
||||
if not casts:
|
||||
logger.error("Could not find Chromecasts")
|
||||
return False
|
||||
|
||||
def update_chromecast_state(entity_id, chromecast):
|
||||
""" Retrieve state of Chromecast and update statemachine. """
|
||||
chromecast.refresh()
|
||||
|
||||
status = chromecast.app
|
||||
|
||||
state_attr = {ATTR_HOST: chromecast.host,
|
||||
components.ATTR_FRIENDLY_NAME:
|
||||
chromecast.device.friendly_name}
|
||||
|
||||
if status and status.app_id != pychromecast.APP_ID['HOME']:
|
||||
state = status.app_id
|
||||
|
||||
ramp = chromecast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp and ramp.state != pychromecast.RAMP_STATE_UNKNOWN:
|
||||
|
||||
if ramp.state == pychromecast.RAMP_STATE_PLAYING:
|
||||
state_attr[ATTR_MEDIA_STATE] = MEDIA_STATE_PLAYING
|
||||
else:
|
||||
state_attr[ATTR_MEDIA_STATE] = MEDIA_STATE_STOPPED
|
||||
|
||||
if ramp.content_id:
|
||||
state_attr[ATTR_MEDIA_CONTENT_ID] = ramp.content_id
|
||||
|
||||
if ramp.title:
|
||||
state_attr[ATTR_MEDIA_TITLE] = ramp.title
|
||||
|
||||
if ramp.artist:
|
||||
state_attr[ATTR_MEDIA_ARTIST] = ramp.artist
|
||||
|
||||
if ramp.album:
|
||||
state_attr[ATTR_MEDIA_ALBUM] = ramp.album
|
||||
|
||||
if ramp.image_url:
|
||||
state_attr[ATTR_MEDIA_IMAGE_URL] = ramp.image_url
|
||||
|
||||
if ramp.duration:
|
||||
state_attr[ATTR_MEDIA_DURATION] = ramp.duration
|
||||
|
||||
state_attr[ATTR_MEDIA_VOLUME] = ramp.volume
|
||||
else:
|
||||
state = STATE_NO_APP
|
||||
|
||||
statemachine.set_state(entity_id, state, state_attr)
|
||||
|
||||
def update_chromecast_states(time): # pylint: disable=unused-argument
|
||||
""" Updates all chromecast states. """
|
||||
logger.info("Updating Chromecast status")
|
||||
|
||||
for entity_id, cast in casts.items():
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
def _service_to_entities(service):
|
||||
""" Helper method to get entities from service. """
|
||||
entity_ids = components.extract_entity_ids(statemachine, service)
|
||||
|
||||
if entity_ids:
|
||||
for entity_id in entity_ids:
|
||||
cast = casts.get(entity_id)
|
||||
|
||||
if cast:
|
||||
yield entity_id, cast
|
||||
|
||||
else:
|
||||
for item in casts.items():
|
||||
yield item
|
||||
|
||||
def turn_off_service(service):
|
||||
""" Service to exit any running app on the specified ChromeCast and
|
||||
shows idle screen. Will quit all ChromeCasts if nothing specified.
|
||||
"""
|
||||
for entity_id, cast in _service_to_entities(service):
|
||||
cast.quit_app()
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
def volume_up_service(service):
|
||||
""" Service to send the chromecast the command for volume up. """
|
||||
for _, cast in _service_to_entities(service):
|
||||
ramp = cast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp:
|
||||
ramp.volume_up()
|
||||
|
||||
def volume_down_service(service):
|
||||
""" Service to send the chromecast the command for volume down. """
|
||||
for _, cast in _service_to_entities(service):
|
||||
ramp = cast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp:
|
||||
ramp.volume_down()
|
||||
|
||||
def media_play_pause_service(service):
|
||||
""" Service to send the chromecast the command for play/pause. """
|
||||
for _, cast in _service_to_entities(service):
|
||||
ramp = cast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp:
|
||||
ramp.playpause()
|
||||
|
||||
def media_next_track_service(service):
|
||||
""" Service to send the chromecast the command for next track. """
|
||||
for entity_id, cast in _service_to_entities(service):
|
||||
ramp = cast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp:
|
||||
ramp.next()
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
def play_youtube_video_service(service, video_id):
|
||||
""" Plays specified video_id on the Chromecast's YouTube channel. """
|
||||
if video_id: # if service.data.get('video') returned None
|
||||
for entity_id, cast in _service_to_entities(service):
|
||||
pychromecast.play_youtube_video(video_id, cast.host)
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
ha.track_time_change(bus, update_chromecast_states)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_TURN_OFF,
|
||||
turn_off_service)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_UP,
|
||||
volume_up_service)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_DOWN,
|
||||
volume_down_service)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_PLAY_PAUSE,
|
||||
media_play_pause_service)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_NEXT_TRACK,
|
||||
media_next_track_service)
|
||||
|
||||
bus.register_service(DOMAIN, "start_fireplace",
|
||||
lambda service:
|
||||
play_youtube_video_service(service, "eyU3bRy2x44"))
|
||||
|
||||
bus.register_service(DOMAIN, "start_epic_sax",
|
||||
lambda service:
|
||||
play_youtube_video_service(service, "kxopViU98Xo"))
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_YOUTUBE_VIDEO,
|
||||
lambda service:
|
||||
play_youtube_video_service(service,
|
||||
service.data.get('video')))
|
||||
|
||||
update_chromecast_states(None)
|
||||
|
||||
return True
|
||||
190
homeassistant/components/configurator.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
homeassistant.components.configurator
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A component to allow pieces of code to request configuration from the user.
|
||||
|
||||
Initiate a request by calling the `request_config` method with a callback.
|
||||
This will return a request id that has to be used for future calls.
|
||||
A callback has to be provided to `request_config` which will be called when
|
||||
the user has submitted configuration information.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers import generate_entity_id
|
||||
from homeassistant.const import EVENT_TIME_CHANGED
|
||||
|
||||
DOMAIN = "configurator"
|
||||
DEPENDENCIES = []
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
SERVICE_CONFIGURE = "configure"
|
||||
|
||||
STATE_CONFIGURE = "configure"
|
||||
STATE_CONFIGURED = "configured"
|
||||
|
||||
ATTR_CONFIGURE_ID = "configure_id"
|
||||
ATTR_DESCRIPTION = "description"
|
||||
ATTR_DESCRIPTION_IMAGE = "description_image"
|
||||
ATTR_SUBMIT_CAPTION = "submit_caption"
|
||||
ATTR_FIELDS = "fields"
|
||||
ATTR_ERRORS = "errors"
|
||||
|
||||
_REQUESTS = {}
|
||||
_INSTANCES = {}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def request_config(
|
||||
hass, name, callback, description=None, description_image=None,
|
||||
submit_caption=None, fields=None):
|
||||
""" Create a new request for config.
|
||||
Will return an ID to be used for sequent calls. """
|
||||
|
||||
instance = _get_instance(hass)
|
||||
|
||||
request_id = instance.request_config(
|
||||
name, callback,
|
||||
description, description_image, submit_caption, fields)
|
||||
|
||||
_REQUESTS[request_id] = instance
|
||||
|
||||
return request_id
|
||||
|
||||
|
||||
def notify_errors(request_id, error):
|
||||
""" Add errors to a config request. """
|
||||
try:
|
||||
_REQUESTS[request_id].notify_errors(request_id, error)
|
||||
except KeyError:
|
||||
# If request_id does not exist
|
||||
pass
|
||||
|
||||
|
||||
def request_done(request_id):
|
||||
""" Mark a config request as done. """
|
||||
try:
|
||||
_REQUESTS.pop(request_id).request_done(request_id)
|
||||
except KeyError:
|
||||
# If request_id does not exist
|
||||
pass
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Set up Configurator. """
|
||||
return True
|
||||
|
||||
|
||||
def _get_instance(hass):
|
||||
""" Get an instance per hass object. """
|
||||
try:
|
||||
return _INSTANCES[hass]
|
||||
except KeyError:
|
||||
_INSTANCES[hass] = Configurator(hass)
|
||||
|
||||
if DOMAIN not in hass.config.components:
|
||||
hass.config.components.append(DOMAIN)
|
||||
|
||||
return _INSTANCES[hass]
|
||||
|
||||
|
||||
class Configurator(object):
|
||||
"""
|
||||
Class to keep track of current configuration requests.
|
||||
"""
|
||||
|
||||
def __init__(self, hass):
|
||||
self.hass = hass
|
||||
self._cur_id = 0
|
||||
self._requests = {}
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_CONFIGURE, self.handle_service_call)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def request_config(
|
||||
self, name, callback,
|
||||
description, description_image, submit_caption, fields):
|
||||
""" Setup a request for configuration. """
|
||||
|
||||
entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass)
|
||||
|
||||
if fields is None:
|
||||
fields = []
|
||||
|
||||
request_id = self._generate_unique_id()
|
||||
|
||||
self._requests[request_id] = (entity_id, fields, callback)
|
||||
|
||||
data = {
|
||||
ATTR_CONFIGURE_ID: request_id,
|
||||
ATTR_FIELDS: fields,
|
||||
}
|
||||
|
||||
data.update({
|
||||
key: value for key, value in [
|
||||
(ATTR_DESCRIPTION, description),
|
||||
(ATTR_DESCRIPTION_IMAGE, description_image),
|
||||
(ATTR_SUBMIT_CAPTION, submit_caption),
|
||||
] if value is not None
|
||||
})
|
||||
|
||||
self.hass.states.set(entity_id, STATE_CONFIGURE, data)
|
||||
|
||||
return request_id
|
||||
|
||||
def notify_errors(self, request_id, error):
|
||||
""" Update the state with errors. """
|
||||
if not self._validate_request_id(request_id):
|
||||
return
|
||||
|
||||
entity_id = self._requests[request_id][0]
|
||||
|
||||
state = self.hass.states.get(entity_id)
|
||||
|
||||
new_data = state.attributes
|
||||
new_data[ATTR_ERRORS] = error
|
||||
|
||||
self.hass.states.set(entity_id, STATE_CONFIGURE, new_data)
|
||||
|
||||
def request_done(self, request_id):
|
||||
""" Remove the config request. """
|
||||
if not self._validate_request_id(request_id):
|
||||
return
|
||||
|
||||
entity_id = self._requests.pop(request_id)[0]
|
||||
|
||||
# If we remove the state right away, it will not be included with
|
||||
# the result fo the service call (current design limitation).
|
||||
# Instead, we will set it to configured to give as feedback but delete
|
||||
# it shortly after so that it is deleted when the client updates.
|
||||
self.hass.states.set(entity_id, STATE_CONFIGURED)
|
||||
|
||||
def deferred_remove(event):
|
||||
""" Remove the request state. """
|
||||
self.hass.states.remove(entity_id)
|
||||
|
||||
self.hass.bus.listen_once(EVENT_TIME_CHANGED, deferred_remove)
|
||||
|
||||
def handle_service_call(self, call):
|
||||
""" Handle a configure service call. """
|
||||
request_id = call.data.get(ATTR_CONFIGURE_ID)
|
||||
|
||||
if not self._validate_request_id(request_id):
|
||||
return
|
||||
|
||||
# pylint: disable=unused-variable
|
||||
entity_id, fields, callback = self._requests[request_id]
|
||||
|
||||
# field validation goes here?
|
||||
|
||||
callback(call.data.get(ATTR_FIELDS, {}))
|
||||
|
||||
def _generate_unique_id(self):
|
||||
""" Generates a unique configurator id. """
|
||||
self._cur_id += 1
|
||||
return "{}-{}".format(id(self), self._cur_id)
|
||||
|
||||
def _validate_request_id(self, request_id):
|
||||
""" Validate that the request belongs to this instance. """
|
||||
return request_id in self._requests
|
||||
70
homeassistant/components/conversation.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
homeassistant.components.conversation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to have conversations with Home Assistant.
|
||||
This is more a proof of concept.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF)
|
||||
|
||||
DOMAIN = "conversation"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_PROCESS = "process"
|
||||
|
||||
ATTR_TEXT = "text"
|
||||
|
||||
REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)')
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Registers the process service. """
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def process(service):
|
||||
""" Parses text into commands for Home Assistant. """
|
||||
if ATTR_TEXT not in service.data:
|
||||
logger.error("Received process service call without a text")
|
||||
return
|
||||
|
||||
text = service.data[ATTR_TEXT].lower()
|
||||
|
||||
match = REGEX_TURN_COMMAND.match(text)
|
||||
|
||||
if not match:
|
||||
logger.error("Unable to process: %s", text)
|
||||
return
|
||||
|
||||
name, command = match.groups()
|
||||
|
||||
entity_ids = [
|
||||
state.entity_id for state in hass.states.all()
|
||||
if state.name.lower() == name]
|
||||
|
||||
if not entity_ids:
|
||||
logger.error(
|
||||
"Could not find entity id %s from text %s", name, text)
|
||||
return
|
||||
|
||||
if command == 'on':
|
||||
hass.services.call(core.DOMAIN, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
}, blocking=True)
|
||||
|
||||
elif command == 'off':
|
||||
hass.services.call(core.DOMAIN, SERVICE_TURN_OFF, {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
}, blocking=True)
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
'Got unsupported command %s from text %s', command, text)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_PROCESS, process)
|
||||
|
||||
return True
|
||||
151
homeassistant/components/demo.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
homeassistant.components.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Sets up a demo environment that mimics interaction with devices.
|
||||
"""
|
||||
import time
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.bootstrap as bootstrap
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.const import (
|
||||
CONF_PLATFORM, ATTR_ENTITY_PICTURE, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME)
|
||||
|
||||
DOMAIN = "demo"
|
||||
|
||||
DEPENDENCIES = ['introduction', 'conversation']
|
||||
|
||||
COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
'switch', 'light', 'sensor', 'thermostat', 'media_player', 'notify']
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup a demo environment. """
|
||||
group = loader.get_component('group')
|
||||
configurator = loader.get_component('configurator')
|
||||
|
||||
config.setdefault(ha.DOMAIN, {})
|
||||
config.setdefault(DOMAIN, {})
|
||||
|
||||
if config[DOMAIN].get('hide_demo_state') != 1:
|
||||
hass.states.set('a.Demo_Mode', 'Enabled')
|
||||
|
||||
# Setup sun
|
||||
if not hass.config.latitude:
|
||||
hass.config.latitude = 32.87336
|
||||
|
||||
if not hass.config.longitude:
|
||||
hass.config.longitude = 117.22743
|
||||
|
||||
bootstrap.setup_component(hass, 'sun')
|
||||
|
||||
# Setup demo platforms
|
||||
for component in COMPONENTS_WITH_DEMO_PLATFORM:
|
||||
bootstrap.setup_component(
|
||||
hass, component, {component: {CONF_PLATFORM: 'demo'}})
|
||||
|
||||
# Setup room groups
|
||||
lights = sorted(hass.states.entity_ids('light'))
|
||||
switches = sorted(hass.states.entity_ids('switch'))
|
||||
media_players = sorted(hass.states.entity_ids('media_player'))
|
||||
group.setup_group(hass, 'living room', [lights[2], lights[1], switches[0],
|
||||
media_players[1]])
|
||||
group.setup_group(hass, 'bedroom', [lights[0], switches[1],
|
||||
media_players[0]])
|
||||
|
||||
# Setup IP Camera
|
||||
bootstrap.setup_component(
|
||||
hass, 'camera',
|
||||
{'camera': {
|
||||
'platform': 'generic',
|
||||
'name': 'IP Camera',
|
||||
'still_image_url': 'http://home-assistant.io/demo/webcam.jpg',
|
||||
}})
|
||||
|
||||
# Setup scripts
|
||||
bootstrap.setup_component(
|
||||
hass, 'script',
|
||||
{'script': {
|
||||
'demo': {
|
||||
'alias': 'Toggle {}'.format(lights[0].split('.')[1]),
|
||||
'sequence': [{
|
||||
'execute_service': 'light.turn_off',
|
||||
'service_data': {ATTR_ENTITY_ID: lights[0]}
|
||||
}, {
|
||||
'delay': {'seconds': 5}
|
||||
}, {
|
||||
'execute_service': 'light.turn_on',
|
||||
'service_data': {ATTR_ENTITY_ID: lights[0]}
|
||||
}, {
|
||||
'delay': {'seconds': 5}
|
||||
}, {
|
||||
'execute_service': 'light.turn_off',
|
||||
'service_data': {ATTR_ENTITY_ID: lights[0]}
|
||||
}]
|
||||
}}})
|
||||
|
||||
# Setup scenes
|
||||
bootstrap.setup_component(
|
||||
hass, 'scene',
|
||||
{'scene': [
|
||||
{'name': 'Romantic lights',
|
||||
'entities': {
|
||||
lights[0]: True,
|
||||
lights[1]: {'state': 'on', 'xy_color': [0.33, 0.66],
|
||||
'brightness': 200},
|
||||
}},
|
||||
{'name': 'Switch on and off',
|
||||
'entities': {
|
||||
switches[0]: True,
|
||||
switches[1]: False,
|
||||
}},
|
||||
]})
|
||||
|
||||
# Setup fake device tracker
|
||||
hass.states.set("device_tracker.paulus", "home",
|
||||
{ATTR_ENTITY_PICTURE:
|
||||
"http://graph.facebook.com/297400035/picture",
|
||||
ATTR_FRIENDLY_NAME: 'Paulus'})
|
||||
hass.states.set("device_tracker.anne_therese", "not_home",
|
||||
{ATTR_FRIENDLY_NAME: 'Anne Therese',
|
||||
'latitude': hass.config.latitude + 0.002,
|
||||
'longitude': hass.config.longitude + 0.002})
|
||||
|
||||
hass.states.set("group.all_devices", "home",
|
||||
{
|
||||
"auto": True,
|
||||
ATTR_ENTITY_ID: [
|
||||
"device_tracker.paulus",
|
||||
"device_tracker.anne_therese"
|
||||
]
|
||||
})
|
||||
|
||||
# Setup configurator
|
||||
configurator_ids = []
|
||||
|
||||
def hue_configuration_callback(data):
|
||||
""" Fake callback, mark config as done. """
|
||||
time.sleep(2)
|
||||
|
||||
# First time it is called, pretend it failed.
|
||||
if len(configurator_ids) == 1:
|
||||
configurator.notify_errors(
|
||||
configurator_ids[0],
|
||||
"Failed to register, please try again.")
|
||||
|
||||
configurator_ids.append(0)
|
||||
else:
|
||||
configurator.request_done(configurator_ids[0])
|
||||
|
||||
request_id = configurator.request_config(
|
||||
hass, "Philips Hue", hue_configuration_callback,
|
||||
description=("Press the button on the bridge to register Philips Hue "
|
||||
"with Home Assistant."),
|
||||
description_image="/static/images/config_philips_hue.jpg",
|
||||
submit_caption="I have pressed the button"
|
||||
)
|
||||
|
||||
configurator_ids.append(request_id)
|
||||
|
||||
return True
|
||||
@@ -6,30 +6,44 @@ Provides functionality to turn on lights based on
|
||||
the state of the sun and devices.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.util as util
|
||||
import homeassistant.components as components
|
||||
from homeassistant.helpers.event import track_point_in_time, track_state_change
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
|
||||
from . import light, sun, device_tracker, group
|
||||
|
||||
DOMAIN = "device_sun_light_trigger"
|
||||
DEPENDENCIES = ['light', 'device_tracker', 'group', 'sun']
|
||||
|
||||
LIGHT_TRANSITION_TIME = timedelta(minutes=15)
|
||||
|
||||
# Light profile to be used if none given
|
||||
LIGHT_PROFILE = 'relax'
|
||||
|
||||
CONF_LIGHT_PROFILE = 'light_profile'
|
||||
CONF_LIGHT_GROUP = 'light_group'
|
||||
CONF_DEVICE_GROUP = 'device_group'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def setup(bus, statemachine,
|
||||
light_group=light.GROUP_NAME_ALL_LIGHTS,
|
||||
light_profile=LIGHT_PROFILE):
|
||||
def setup(hass, config):
|
||||
""" Triggers to turn lights on or off based on device precense. """
|
||||
|
||||
disable_turn_off = 'disable_turn_off' in config[DOMAIN]
|
||||
|
||||
light_group = config[DOMAIN].get(CONF_LIGHT_GROUP,
|
||||
light.ENTITY_ID_ALL_LIGHTS)
|
||||
|
||||
light_profile = config[DOMAIN].get(CONF_LIGHT_PROFILE, LIGHT_PROFILE)
|
||||
|
||||
device_group = config[DOMAIN].get(CONF_DEVICE_GROUP,
|
||||
device_tracker.ENTITY_ID_ALL_DEVICES)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
device_entity_ids = util.filter_entity_ids(statemachine.entity_ids,
|
||||
device_tracker.DOMAIN)
|
||||
device_entity_ids = group.get_entity_ids(hass, device_group,
|
||||
device_tracker.DOMAIN)
|
||||
|
||||
if not device_entity_ids:
|
||||
logger.error("No devices found to track")
|
||||
@@ -37,8 +51,7 @@ def setup(bus, statemachine,
|
||||
return False
|
||||
|
||||
# Get the light IDs from the specified group
|
||||
light_ids = util.filter_entity_ids(
|
||||
group.get_entity_ids(statemachine, light_group), light.DOMAIN)
|
||||
light_ids = group.get_entity_ids(hass, light_group, light.DOMAIN)
|
||||
|
||||
if not light_ids:
|
||||
logger.error("No lights found to turn on ")
|
||||
@@ -48,14 +61,13 @@ def setup(bus, statemachine,
|
||||
def calc_time_for_light_when_sunset():
|
||||
""" Calculates the time when to start fading lights in when sun sets.
|
||||
Returns None if no next_setting data available. """
|
||||
next_setting = sun.next_setting(statemachine)
|
||||
next_setting = sun.next_setting(hass)
|
||||
|
||||
if next_setting:
|
||||
return (next_setting - LIGHT_TRANSITION_TIME * len(light_ids))
|
||||
return next_setting - LIGHT_TRANSITION_TIME * len(light_ids)
|
||||
else:
|
||||
return None
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def schedule_light_on_sun_rise(entity, old_state, new_state):
|
||||
"""The moment sun sets we want to have all the lights on.
|
||||
We will schedule to have each light start after one another
|
||||
@@ -64,10 +76,9 @@ def setup(bus, statemachine,
|
||||
def turn_light_on_before_sunset(light_id):
|
||||
""" Helper function to turn on lights slowly if there
|
||||
are devices home and the light is not on yet. """
|
||||
if (device_tracker.is_on(statemachine) and
|
||||
not light.is_on(statemachine, light_id)):
|
||||
if device_tracker.is_on(hass) and not light.is_on(hass, light_id):
|
||||
|
||||
light.turn_on(bus, light_id,
|
||||
light.turn_on(hass, light_id,
|
||||
transition=LIGHT_TRANSITION_TIME.seconds,
|
||||
profile=light_profile)
|
||||
|
||||
@@ -81,58 +92,55 @@ def setup(bus, statemachine,
|
||||
|
||||
if start_point:
|
||||
for index, light_id in enumerate(light_ids):
|
||||
ha.track_point_in_time(bus, turn_on(light_id),
|
||||
(start_point +
|
||||
index * LIGHT_TRANSITION_TIME))
|
||||
track_point_in_time(
|
||||
hass, turn_on(light_id),
|
||||
(start_point + index * LIGHT_TRANSITION_TIME))
|
||||
|
||||
# Track every time sun rises so we can schedule a time-based
|
||||
# pre-sun set event
|
||||
ha.track_state_change(bus, sun.ENTITY_ID,
|
||||
schedule_light_on_sun_rise,
|
||||
sun.STATE_BELOW_HORIZON, sun.STATE_ABOVE_HORIZON)
|
||||
track_state_change(hass, sun.ENTITY_ID, schedule_light_on_sun_rise,
|
||||
sun.STATE_BELOW_HORIZON, sun.STATE_ABOVE_HORIZON)
|
||||
|
||||
# If the sun is already above horizon
|
||||
# schedule the time-based pre-sun set event
|
||||
if sun.is_on(statemachine):
|
||||
if sun.is_on(hass):
|
||||
schedule_light_on_sun_rise(None, None, None)
|
||||
|
||||
def check_light_on_dev_state_change(entity, old_state, new_state):
|
||||
""" Function to handle tracked device state changes. """
|
||||
lights_are_on = group.is_on(statemachine, light_group)
|
||||
lights_are_on = group.is_on(hass, light_group)
|
||||
|
||||
light_needed = not (lights_are_on or sun.is_on(statemachine))
|
||||
light_needed = not (lights_are_on or sun.is_on(hass))
|
||||
|
||||
# Specific device came home ?
|
||||
if (entity != device_tracker.ENTITY_ID_ALL_DEVICES and
|
||||
new_state.state == components.STATE_HOME):
|
||||
if entity != device_tracker.ENTITY_ID_ALL_DEVICES and \
|
||||
new_state.state == STATE_HOME:
|
||||
|
||||
# These variables are needed for the elif check
|
||||
now = datetime.now()
|
||||
now = dt_util.now()
|
||||
start_point = calc_time_for_light_when_sunset()
|
||||
|
||||
# Do we need lights?
|
||||
if light_needed:
|
||||
|
||||
logger.info(
|
||||
"Home coming event for {}. Turning lights on".
|
||||
format(entity))
|
||||
"Home coming event for %s. Turning lights on", entity)
|
||||
|
||||
light.turn_on(bus, light_ids,
|
||||
profile=light_profile)
|
||||
light.turn_on(hass, light_ids, profile=light_profile)
|
||||
|
||||
# Are we in the time span were we would turn on the lights
|
||||
# if someone would be home?
|
||||
# Check this by seeing if current time is later then the point
|
||||
# in time when we would start putting the lights on.
|
||||
elif (start_point and
|
||||
start_point < now < sun.next_setting(statemachine)):
|
||||
start_point < now < sun.next_setting(hass)):
|
||||
|
||||
# Check for every light if it would be on if someone was home
|
||||
# when the fading in started and turn it on if so
|
||||
for index, light_id in enumerate(light_ids):
|
||||
|
||||
if now > start_point + index * LIGHT_TRANSITION_TIME:
|
||||
light.turn_on(bus, light_id)
|
||||
light.turn_on(hass, light_id)
|
||||
|
||||
else:
|
||||
# If this light didn't happen to be turned on yet so
|
||||
@@ -140,22 +148,23 @@ def setup(bus, statemachine,
|
||||
break
|
||||
|
||||
# Did all devices leave the house?
|
||||
elif (entity == device_tracker.ENTITY_ID_ALL_DEVICES and
|
||||
new_state.state == components.STATE_NOT_HOME and lights_are_on):
|
||||
elif (entity == device_group and
|
||||
new_state.state == STATE_NOT_HOME and lights_are_on and
|
||||
not disable_turn_off):
|
||||
|
||||
logger.info(
|
||||
"Everyone has left but there are devices on. Turning them off")
|
||||
"Everyone has left but there are lights on. Turning them off")
|
||||
|
||||
light.turn_off(bus)
|
||||
light.turn_off(hass, light_ids)
|
||||
|
||||
# Track home coming of each seperate device
|
||||
for entity in device_entity_ids:
|
||||
ha.track_state_change(bus, entity, check_light_on_dev_state_change,
|
||||
components.STATE_NOT_HOME, components.STATE_HOME)
|
||||
# Track home coming of each device
|
||||
track_state_change(
|
||||
hass, device_entity_ids, check_light_on_dev_state_change,
|
||||
STATE_NOT_HOME, STATE_HOME)
|
||||
|
||||
# Track when all devices are gone to shut down lights
|
||||
ha.track_state_change(bus, device_tracker.ENTITY_ID_ALL_DEVICES,
|
||||
check_light_on_dev_state_change,
|
||||
components.STATE_HOME, components.STATE_NOT_HOME)
|
||||
track_state_change(
|
||||
hass, device_group, check_light_on_dev_state_change,
|
||||
STATE_HOME, STATE_NOT_HOME)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
"""
|
||||
homeassistant.components.tracker
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to keep track of devices.
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
import os
|
||||
import csv
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.util as util
|
||||
import homeassistant.components as components
|
||||
|
||||
from homeassistant.components import group
|
||||
|
||||
DOMAIN = "device_tracker"
|
||||
|
||||
SERVICE_DEVICE_TRACKER_RELOAD = "reload_devices_csv"
|
||||
|
||||
GROUP_NAME_ALL_DEVICES = 'all_tracked_devices'
|
||||
ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format(
|
||||
GROUP_NAME_ALL_DEVICES)
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
# After how much time do we consider a device not home if
|
||||
# it does not show up on scans
|
||||
TIME_SPAN_FOR_ERROR_IN_SCANNING = timedelta(minutes=3)
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
# Filename to save known devices to
|
||||
KNOWN_DEVICES_FILE = "known_devices.csv"
|
||||
|
||||
|
||||
def is_on(statemachine, entity_id=None):
|
||||
""" Returns if any or specified device is home. """
|
||||
entity = entity_id or ENTITY_ID_ALL_DEVICES
|
||||
|
||||
return statemachine.is_state(entity, components.STATE_HOME)
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class DeviceTracker(object):
|
||||
""" Class that tracks which devices are home and which are not. """
|
||||
|
||||
def __init__(self, bus, statemachine, device_scanner, error_scanning=None):
|
||||
self.statemachine = statemachine
|
||||
self.bus = bus
|
||||
self.device_scanner = device_scanner
|
||||
|
||||
self.error_scanning = error_scanning or TIME_SPAN_FOR_ERROR_IN_SCANNING
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Dictionary to keep track of known devices and devices we track
|
||||
self.known_devices = {}
|
||||
|
||||
# Did we encounter an invalid known devices file
|
||||
self.invalid_known_devices_file = False
|
||||
|
||||
self._read_known_devices_file()
|
||||
|
||||
# Wrap it in a func instead of lambda so it can be identified in
|
||||
# the bus by its __name__ attribute.
|
||||
def update_device_state(time): # pylint: disable=unused-argument
|
||||
""" Triggers update of the device states. """
|
||||
self.update_devices()
|
||||
|
||||
ha.track_time_change(bus, update_device_state)
|
||||
|
||||
bus.register_service(DOMAIN,
|
||||
SERVICE_DEVICE_TRACKER_RELOAD,
|
||||
lambda service: self._read_known_devices_file())
|
||||
|
||||
self.update_devices()
|
||||
|
||||
group.setup(bus, statemachine, GROUP_NAME_ALL_DEVICES,
|
||||
list(self.device_entity_ids))
|
||||
|
||||
@property
|
||||
def device_entity_ids(self):
|
||||
""" Returns a set containing all device entity ids
|
||||
that are being tracked. """
|
||||
return set([self.known_devices[device]['entity_id'] for device
|
||||
in self.known_devices
|
||||
if self.known_devices[device]['track']])
|
||||
|
||||
def update_devices(self, found_devices=None):
|
||||
""" Update device states based on the found devices. """
|
||||
self.lock.acquire()
|
||||
|
||||
found_devices = found_devices or self.device_scanner.scan_devices()
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
known_dev = self.known_devices
|
||||
|
||||
temp_tracking_devices = [device for device in known_dev
|
||||
if known_dev[device]['track']]
|
||||
|
||||
for device in found_devices:
|
||||
# Are we tracking this device?
|
||||
if device in temp_tracking_devices:
|
||||
temp_tracking_devices.remove(device)
|
||||
|
||||
known_dev[device]['last_seen'] = now
|
||||
|
||||
self.statemachine.set_state(
|
||||
known_dev[device]['entity_id'], components.STATE_HOME)
|
||||
|
||||
# For all devices we did not find, set state to NH
|
||||
# But only if they have been gone for longer then the error time span
|
||||
# Because we do not want to have stuff happening when the device does
|
||||
# not show up for 1 scan beacuse of reboot etc
|
||||
for device in temp_tracking_devices:
|
||||
if (now - known_dev[device]['last_seen'] > self.error_scanning):
|
||||
|
||||
self.statemachine.set_state(known_dev[device]['entity_id'],
|
||||
components.STATE_NOT_HOME)
|
||||
|
||||
# If we come along any unknown devices we will write them to the
|
||||
# known devices file but only if we did not encounter an invalid
|
||||
# known devices file
|
||||
if not self.invalid_known_devices_file:
|
||||
|
||||
unknown_devices = [device for device in found_devices
|
||||
if device not in known_dev]
|
||||
|
||||
if unknown_devices:
|
||||
try:
|
||||
# If file does not exist we will write the header too
|
||||
is_new_file = not os.path.isfile(KNOWN_DEVICES_FILE)
|
||||
|
||||
with open(KNOWN_DEVICES_FILE, 'a') as outp:
|
||||
self.logger.info((
|
||||
"DeviceTracker:Found {} new devices,"
|
||||
" updating {}").format(len(unknown_devices),
|
||||
KNOWN_DEVICES_FILE))
|
||||
|
||||
writer = csv.writer(outp)
|
||||
|
||||
if is_new_file:
|
||||
writer.writerow(("device", "name", "track"))
|
||||
|
||||
for device in unknown_devices:
|
||||
# See if the device scanner knows the name
|
||||
# else defaults to unknown device
|
||||
name = (self.device_scanner.get_device_name(device)
|
||||
or "unknown_device")
|
||||
|
||||
writer.writerow((device, name, 0))
|
||||
known_dev[device] = {'name': name,
|
||||
'track': False}
|
||||
|
||||
except IOError:
|
||||
self.logger.exception((
|
||||
"DeviceTracker:Error updating {}"
|
||||
"with {} new devices").format(
|
||||
KNOWN_DEVICES_FILE, len(unknown_devices)))
|
||||
|
||||
self.lock.release()
|
||||
|
||||
def _read_known_devices_file(self):
|
||||
""" Parse and process the known devices file. """
|
||||
|
||||
# Read known devices if file exists
|
||||
if os.path.isfile(KNOWN_DEVICES_FILE):
|
||||
self.lock.acquire()
|
||||
|
||||
known_devices = {}
|
||||
|
||||
with open(KNOWN_DEVICES_FILE) as inp:
|
||||
default_last_seen = datetime(1990, 1, 1)
|
||||
|
||||
# Temp variable to keep track of which entity ids we use
|
||||
# so we can ensure we have unique entity ids.
|
||||
used_entity_ids = []
|
||||
|
||||
try:
|
||||
for row in csv.DictReader(inp):
|
||||
device = row['device']
|
||||
|
||||
row['track'] = True if row['track'] == '1' else False
|
||||
|
||||
# If we track this device setup tracking variables
|
||||
if row['track']:
|
||||
row['last_seen'] = default_last_seen
|
||||
|
||||
# Make sure that each device is mapped
|
||||
# to a unique entity_id name
|
||||
name = util.slugify(row['name']) if row['name'] \
|
||||
else "unnamed_device"
|
||||
|
||||
entity_id = ENTITY_ID_FORMAT.format(name)
|
||||
tries = 1
|
||||
|
||||
while entity_id in used_entity_ids:
|
||||
tries += 1
|
||||
|
||||
suffix = "_{}".format(tries)
|
||||
|
||||
entity_id = ENTITY_ID_FORMAT.format(
|
||||
name + suffix)
|
||||
|
||||
row['entity_id'] = entity_id
|
||||
used_entity_ids.append(entity_id)
|
||||
|
||||
known_devices[device] = row
|
||||
|
||||
if not known_devices:
|
||||
self.logger.warning(
|
||||
"No devices to track. Please update {}.".format(
|
||||
KNOWN_DEVICES_FILE))
|
||||
|
||||
# Remove entities that are no longer maintained
|
||||
new_entity_ids = set([known_devices[device]['entity_id']
|
||||
for device in known_devices
|
||||
if known_devices[device]['track']])
|
||||
|
||||
for entity_id in \
|
||||
self.device_entity_ids - new_entity_ids:
|
||||
|
||||
self.logger.info(
|
||||
"DeviceTracker:Removing entity {}".format(
|
||||
entity_id))
|
||||
self.statemachine.remove_entity(entity_id)
|
||||
|
||||
# File parsed, warnings given if necessary
|
||||
# entities cleaned up, make it available
|
||||
self.known_devices = known_devices
|
||||
|
||||
self.logger.info(
|
||||
"DeviceTracker:Loaded devices from {}".format(
|
||||
KNOWN_DEVICES_FILE))
|
||||
|
||||
except KeyError:
|
||||
self.invalid_known_devices_file = True
|
||||
self.logger.warning((
|
||||
"Invalid {} found. "
|
||||
"We won't update it with new found devices.").
|
||||
format(KNOWN_DEVICES_FILE))
|
||||
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
|
||||
class TomatoDeviceScanner(object):
|
||||
""" This class queries a wireless router running Tomato firmware
|
||||
for connected devices.
|
||||
|
||||
A description of the Tomato API can be found on
|
||||
http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/
|
||||
"""
|
||||
|
||||
def __init__(self, host, username, password, http_id):
|
||||
self.req = requests.Request('POST',
|
||||
'http://{}/update.cgi'.format(host),
|
||||
data={'_http_id': http_id,
|
||||
'exec': 'devlist'},
|
||||
auth=requests.auth.HTTPBasicAuth(
|
||||
username, password)).prepare()
|
||||
|
||||
self.parse_api_pattern = re.compile(r"(?P<param>\w*) = (?P<value>.*);")
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.date_updated = None
|
||||
self.last_results = {"wldev": [], "dhcpd_lease": []}
|
||||
|
||||
self.success_init = self._update_tomato_info()
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
|
||||
self._update_tomato_info()
|
||||
|
||||
return [item[1] for item in self.last_results['wldev']]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
# Make sure there are results
|
||||
if not self.date_updated:
|
||||
self._update_tomato_info()
|
||||
|
||||
filter_named = [item[0] for item in self.last_results['dhcpd_lease']
|
||||
if item[2] == device]
|
||||
|
||||
if not filter_named or not filter_named[0]:
|
||||
return None
|
||||
else:
|
||||
return filter_named[0]
|
||||
|
||||
def _update_tomato_info(self):
|
||||
""" Ensures the information from the Tomato router is up to date.
|
||||
Returns boolean if scanning successful. """
|
||||
|
||||
self.lock.acquire()
|
||||
|
||||
# if date_updated is None or the date is too old we scan for new data
|
||||
if (not self.date_updated or datetime.now() - self.date_updated >
|
||||
MIN_TIME_BETWEEN_SCANS):
|
||||
|
||||
self.logger.info("Tomato:Scanning")
|
||||
|
||||
try:
|
||||
response = requests.Session().send(self.req, timeout=3)
|
||||
|
||||
# Calling and parsing the Tomato api here. We only need the
|
||||
# wldev and dhcpd_lease values. For API description see:
|
||||
# http://paulusschoutsen.nl/
|
||||
# blog/2013/10/tomato-api-documentation/
|
||||
if response.status_code == 200:
|
||||
|
||||
for param, value in \
|
||||
self.parse_api_pattern.findall(response.text):
|
||||
|
||||
if param == 'wldev' or param == 'dhcpd_lease':
|
||||
self.last_results[param] = \
|
||||
json.loads(value.replace("'", '"'))
|
||||
|
||||
self.date_updated = datetime.now()
|
||||
|
||||
return True
|
||||
|
||||
elif response.status_code == 401:
|
||||
# Authentication error
|
||||
self.logger.exception((
|
||||
"Tomato:Failed to authenticate, "
|
||||
"please check your username and password"))
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
# We get this if we could not connect to the router or
|
||||
# an invalid http_id was supplied
|
||||
self.logger.exception((
|
||||
"Tomato:Failed to connect to the router"
|
||||
" or invalid http_id supplied"))
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
# We get this if we could not connect to the router or
|
||||
# an invalid http_id was supplied
|
||||
self.logger.exception(
|
||||
"Tomato:Connection to the router timed out")
|
||||
|
||||
return False
|
||||
|
||||
except ValueError:
|
||||
# If json decoder could not parse the response
|
||||
self.logger.exception(
|
||||
"Tomato:Failed to parse response from router")
|
||||
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
else:
|
||||
# We acquired the lock before the IF check,
|
||||
# release it before we return True
|
||||
self.lock.release()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class NetgearDeviceScanner(object):
|
||||
""" This class queries a Netgear wireless router using the SOAP-api. """
|
||||
|
||||
def __init__(self, host, username, password):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.date_updated = None
|
||||
self.last_results = []
|
||||
|
||||
try:
|
||||
import homeassistant.external.pynetgear as pynetgear
|
||||
except ImportError:
|
||||
self.logger.exception(
|
||||
("Netgear:Failed to import pynetgear. "
|
||||
"Did you maybe not cloned the git submodules?"))
|
||||
|
||||
self.success_init = False
|
||||
|
||||
return
|
||||
|
||||
self._api = pynetgear.Netgear(host, username, password)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.logger.info("Netgear:Logging in")
|
||||
if self._api.login():
|
||||
self.success_init = True
|
||||
self._update_info()
|
||||
|
||||
else:
|
||||
self.logger.error("Netgear:Failed to Login")
|
||||
|
||||
self.success_init = False
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
|
||||
self._update_info()
|
||||
|
||||
return [device.mac for device in self.last_results]
|
||||
|
||||
def get_device_name(self, mac):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
# Make sure there are results
|
||||
if not self.date_updated:
|
||||
self._update_info()
|
||||
|
||||
filter_named = [device.name for device in self.last_results
|
||||
if device.mac == mac]
|
||||
|
||||
if filter_named:
|
||||
return filter_named[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
def _update_info(self):
|
||||
""" Retrieves latest information from the Netgear router.
|
||||
Returns boolean if scanning successful. """
|
||||
if not self.success_init:
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
# if date_updated is None or the date is too old we scan for
|
||||
# new data
|
||||
if (not self.date_updated or datetime.now() - self.date_updated >
|
||||
MIN_TIME_BETWEEN_SCANS):
|
||||
|
||||
self.logger.info("Netgear:Scanning")
|
||||
|
||||
self.last_results = self._api.get_attached_devices()
|
||||
|
||||
self.date_updated = datetime.now()
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
return
|
||||
446
homeassistant/components/device_tracker/__init__.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to keep track of devices.
|
||||
|
||||
device_tracker:
|
||||
platform: netgear
|
||||
|
||||
# Optional
|
||||
|
||||
# How many seconds to wait after not seeing device to consider it not home
|
||||
consider_home: 180
|
||||
|
||||
# Seconds between each scan
|
||||
interval_seconds: 12
|
||||
|
||||
# New found devices auto found
|
||||
track_new_devices: yes
|
||||
|
||||
# Maximum distance from home we consider people home
|
||||
range_home: 100
|
||||
"""
|
||||
# pylint: disable=too-many-instance-attributes, too-many-arguments
|
||||
# pylint: disable=too-many-locals
|
||||
import csv
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from homeassistant.bootstrap import prepare_setup_platform
|
||||
from homeassistant.components import discovery, group, zone
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_per_platform
|
||||
from homeassistant.helpers.entity import Entity
|
||||
import homeassistant.util as util
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from homeassistant.helpers.event import track_utc_time_change
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_PICTURE, ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
|
||||
DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME)
|
||||
|
||||
DOMAIN = "device_tracker"
|
||||
DEPENDENCIES = ['zone']
|
||||
|
||||
GROUP_NAME_ALL_DEVICES = 'all devices'
|
||||
ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices')
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
CSV_DEVICES = "known_devices.csv"
|
||||
YAML_DEVICES = 'known_devices.yaml'
|
||||
|
||||
CONF_TRACK_NEW = "track_new_devices"
|
||||
DEFAULT_CONF_TRACK_NEW = True
|
||||
|
||||
CONF_CONSIDER_HOME = 'consider_home'
|
||||
DEFAULT_CONSIDER_HOME = 180 # seconds
|
||||
|
||||
CONF_SCAN_INTERVAL = "interval_seconds"
|
||||
DEFAULT_SCAN_INTERVAL = 12
|
||||
|
||||
CONF_AWAY_HIDE = 'hide_if_away'
|
||||
DEFAULT_AWAY_HIDE = False
|
||||
|
||||
CONF_HOME_RANGE = 'home_range'
|
||||
DEFAULT_HOME_RANGE = 100
|
||||
|
||||
SERVICE_SEE = 'see'
|
||||
|
||||
ATTR_MAC = 'mac'
|
||||
ATTR_DEV_ID = 'dev_id'
|
||||
ATTR_HOST_NAME = 'host_name'
|
||||
ATTR_LOCATION_NAME = 'location_name'
|
||||
ATTR_GPS = 'gps'
|
||||
ATTR_BATTERY = 'battery'
|
||||
|
||||
DISCOVERY_PLATFORMS = {
|
||||
discovery.SERVICE_NETGEAR: 'netgear',
|
||||
}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
|
||||
|
||||
def is_on(hass, entity_id=None):
|
||||
""" Returns if any or specified device is home. """
|
||||
entity = entity_id or ENTITY_ID_ALL_DEVICES
|
||||
|
||||
return hass.states.is_state(entity, STATE_HOME)
|
||||
|
||||
|
||||
def see(hass, mac=None, dev_id=None, host_name=None, location_name=None,
|
||||
gps=None, gps_accuracy=None, battery=None):
|
||||
""" Call service to notify you see device. """
|
||||
data = {key: value for key, value in
|
||||
((ATTR_MAC, mac),
|
||||
(ATTR_DEV_ID, dev_id),
|
||||
(ATTR_HOST_NAME, host_name),
|
||||
(ATTR_LOCATION_NAME, location_name),
|
||||
(ATTR_GPS, gps)) if value is not None}
|
||||
hass.services.call(DOMAIN, SERVICE_SEE, data)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup device tracker """
|
||||
yaml_path = hass.config.path(YAML_DEVICES)
|
||||
csv_path = hass.config.path(CSV_DEVICES)
|
||||
if os.path.isfile(csv_path) and not os.path.isfile(yaml_path) and \
|
||||
convert_csv_config(csv_path, yaml_path):
|
||||
os.remove(csv_path)
|
||||
|
||||
conf = config.get(DOMAIN, {})
|
||||
consider_home = timedelta(
|
||||
seconds=util.convert(conf.get(CONF_CONSIDER_HOME), int,
|
||||
DEFAULT_CONSIDER_HOME))
|
||||
track_new = util.convert(conf.get(CONF_TRACK_NEW), bool,
|
||||
DEFAULT_CONF_TRACK_NEW)
|
||||
home_range = util.convert(conf.get(CONF_HOME_RANGE), int,
|
||||
DEFAULT_HOME_RANGE)
|
||||
|
||||
devices = load_config(yaml_path, hass, consider_home, home_range)
|
||||
tracker = DeviceTracker(hass, consider_home, track_new, home_range,
|
||||
devices)
|
||||
|
||||
def setup_platform(p_type, p_config, disc_info=None):
|
||||
""" Setup a device tracker platform. """
|
||||
platform = prepare_setup_platform(hass, config, DOMAIN, p_type)
|
||||
if platform is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if hasattr(platform, 'get_scanner'):
|
||||
scanner = platform.get_scanner(hass, {DOMAIN: p_config})
|
||||
|
||||
if scanner is None:
|
||||
_LOGGER.error('Error setting up platform %s', p_type)
|
||||
return
|
||||
|
||||
setup_scanner_platform(hass, p_config, scanner, tracker.see)
|
||||
return
|
||||
|
||||
if not platform.setup_scanner(hass, p_config, tracker.see):
|
||||
_LOGGER.error('Error setting up platform %s', p_type)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception('Error setting up platform %s', p_type)
|
||||
|
||||
for p_type, p_config in \
|
||||
config_per_platform(config, DOMAIN, _LOGGER):
|
||||
setup_platform(p_type, p_config)
|
||||
|
||||
def device_tracker_discovered(service, info):
|
||||
""" Called when a device tracker platform is discovered. """
|
||||
setup_platform(DISCOVERY_PLATFORMS[service], {}, info)
|
||||
|
||||
discovery.listen(hass, DISCOVERY_PLATFORMS.keys(),
|
||||
device_tracker_discovered)
|
||||
|
||||
def update_stale(now):
|
||||
""" Clean up stale devices. """
|
||||
tracker.update_stale(now)
|
||||
track_utc_time_change(hass, update_stale, second=range(0, 60, 5))
|
||||
|
||||
tracker.setup_group()
|
||||
|
||||
def see_service(call):
|
||||
""" Service to see a device. """
|
||||
args = {key: value for key, value in call.data.items() if key in
|
||||
(ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME,
|
||||
ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY)}
|
||||
tracker.see(**args)
|
||||
|
||||
descriptions = load_yaml_config_file(
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
hass.services.register(DOMAIN, SERVICE_SEE, see_service,
|
||||
descriptions.get(SERVICE_SEE))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class DeviceTracker(object):
|
||||
""" Track devices """
|
||||
def __init__(self, hass, consider_home, track_new, home_range, devices):
|
||||
self.hass = hass
|
||||
self.devices = {dev.dev_id: dev for dev in devices}
|
||||
self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac}
|
||||
self.consider_home = consider_home
|
||||
self.track_new = track_new
|
||||
self.home_range = home_range
|
||||
self.lock = threading.Lock()
|
||||
|
||||
for device in devices:
|
||||
if device.track:
|
||||
device.update_ha_state()
|
||||
|
||||
self.group = None
|
||||
|
||||
def see(self, mac=None, dev_id=None, host_name=None, location_name=None,
|
||||
gps=None, gps_accuracy=None, battery=None):
|
||||
""" Notify device tracker that you see a device. """
|
||||
with self.lock:
|
||||
if mac is None and dev_id is None:
|
||||
raise HomeAssistantError('Neither mac or device id passed in')
|
||||
elif mac is not None:
|
||||
mac = mac.upper()
|
||||
device = self.mac_to_dev.get(mac)
|
||||
if not device:
|
||||
dev_id = util.slugify(host_name or '') or util.slugify(mac)
|
||||
else:
|
||||
dev_id = str(dev_id).lower()
|
||||
device = self.devices.get(dev_id)
|
||||
|
||||
if device:
|
||||
device.seen(host_name, location_name, gps, gps_accuracy,
|
||||
battery)
|
||||
if device.track:
|
||||
device.update_ha_state()
|
||||
return
|
||||
|
||||
# If no device can be found, create it
|
||||
device = Device(
|
||||
self.hass, self.consider_home, self.home_range, self.track_new,
|
||||
dev_id, mac, (host_name or dev_id).replace('_', ' '))
|
||||
self.devices[dev_id] = device
|
||||
if mac is not None:
|
||||
self.mac_to_dev[mac] = device
|
||||
|
||||
device.seen(host_name, location_name, gps, gps_accuracy, battery)
|
||||
if device.track:
|
||||
device.update_ha_state()
|
||||
|
||||
# During init, we ignore the group
|
||||
if self.group is not None:
|
||||
self.group.update_tracked_entity_ids(
|
||||
list(self.group.tracking) + [device.entity_id])
|
||||
update_config(self.hass.config.path(YAML_DEVICES), dev_id, device)
|
||||
|
||||
def setup_group(self):
|
||||
""" Initializes group for all tracked devices. """
|
||||
entity_ids = (dev.entity_id for dev in self.devices.values()
|
||||
if dev.track)
|
||||
self.group = group.setup_group(
|
||||
self.hass, GROUP_NAME_ALL_DEVICES, entity_ids, False)
|
||||
|
||||
def update_stale(self, now):
|
||||
""" Update stale devices. """
|
||||
with self.lock:
|
||||
for device in self.devices.values():
|
||||
if (device.track and device.last_update_home and
|
||||
device.stale(now)):
|
||||
device.update_ha_state(True)
|
||||
|
||||
|
||||
class Device(Entity):
|
||||
""" Tracked device. """
|
||||
|
||||
host_name = None
|
||||
location_name = None
|
||||
gps = None
|
||||
gps_accuracy = 0
|
||||
last_seen = None
|
||||
battery = None
|
||||
|
||||
# Track if the last update of this device was HOME
|
||||
last_update_home = False
|
||||
_state = STATE_NOT_HOME
|
||||
|
||||
def __init__(self, hass, consider_home, home_range, track, dev_id, mac,
|
||||
name=None, picture=None, away_hide=False):
|
||||
self.hass = hass
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(dev_id)
|
||||
|
||||
# Timedelta object how long we consider a device home if it is not
|
||||
# detected anymore.
|
||||
self.consider_home = consider_home
|
||||
|
||||
# Distance in meters
|
||||
self.home_range = home_range
|
||||
# Device ID
|
||||
self.dev_id = dev_id
|
||||
self.mac = mac
|
||||
|
||||
# If we should track this device
|
||||
self.track = track
|
||||
|
||||
# Configured name
|
||||
self.config_name = name
|
||||
|
||||
# Configured picture
|
||||
self.config_picture = picture
|
||||
self.away_hide = away_hide
|
||||
|
||||
@property
|
||||
def gps_home(self):
|
||||
""" Return if device is within range of home. """
|
||||
distance = max(
|
||||
0, self.hass.config.distance(*self.gps) - self.gps_accuracy)
|
||||
return self.gps is not None and distance <= self.home_range
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the entity. """
|
||||
return self.config_name or self.host_name or DEVICE_DEFAULT_NAME
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" State of the device. """
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Device state attributes. """
|
||||
attr = {}
|
||||
|
||||
if self.config_picture:
|
||||
attr[ATTR_ENTITY_PICTURE] = self.config_picture
|
||||
|
||||
if self.gps:
|
||||
attr[ATTR_LATITUDE] = self.gps[0]
|
||||
attr[ATTR_LONGITUDE] = self.gps[1]
|
||||
attr[ATTR_GPS_ACCURACY] = self.gps_accuracy
|
||||
|
||||
if self.battery:
|
||||
attr[ATTR_BATTERY] = self.battery
|
||||
|
||||
return attr
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
""" If device should be hidden. """
|
||||
return self.away_hide and self.state != STATE_HOME
|
||||
|
||||
def seen(self, host_name=None, location_name=None, gps=None,
|
||||
gps_accuracy=0, battery=None):
|
||||
""" Mark the device as seen. """
|
||||
self.last_seen = dt_util.utcnow()
|
||||
self.host_name = host_name
|
||||
self.location_name = location_name
|
||||
self.gps_accuracy = gps_accuracy or 0
|
||||
self.battery = battery
|
||||
if gps is None:
|
||||
self.gps = None
|
||||
else:
|
||||
try:
|
||||
self.gps = tuple(float(val) for val in gps)
|
||||
except ValueError:
|
||||
_LOGGER.warning('Could not parse gps value for %s: %s',
|
||||
self.dev_id, gps)
|
||||
self.gps = None
|
||||
self.update()
|
||||
|
||||
def stale(self, now=None):
|
||||
""" Return if device state is stale. """
|
||||
return self.last_seen and \
|
||||
(now or dt_util.utcnow()) - self.last_seen > self.consider_home
|
||||
|
||||
def update(self):
|
||||
""" Update state of entity. """
|
||||
if not self.last_seen:
|
||||
return
|
||||
elif self.location_name:
|
||||
self._state = self.location_name
|
||||
elif self.gps is not None:
|
||||
zone_state = zone.active_zone(self.hass, self.gps[0], self.gps[1],
|
||||
self.gps_accuracy)
|
||||
if zone_state is None:
|
||||
self._state = STATE_NOT_HOME
|
||||
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
|
||||
self._state = STATE_HOME
|
||||
else:
|
||||
self._state = zone_state.name
|
||||
|
||||
elif self.stale():
|
||||
self._state = STATE_NOT_HOME
|
||||
self.last_update_home = False
|
||||
else:
|
||||
self._state = STATE_HOME
|
||||
self.last_update_home = True
|
||||
|
||||
|
||||
def convert_csv_config(csv_path, yaml_path):
|
||||
""" Convert CSV config file format to YAML. """
|
||||
used_ids = set()
|
||||
with open(csv_path) as inp:
|
||||
for row in csv.DictReader(inp):
|
||||
dev_id = util.ensure_unique_string(
|
||||
(util.slugify(row['name']) or DEVICE_DEFAULT_NAME).lower(),
|
||||
used_ids)
|
||||
used_ids.add(dev_id)
|
||||
device = Device(None, None, None, row['track'] == '1', dev_id,
|
||||
row['device'], row['name'], row['picture'])
|
||||
update_config(yaml_path, dev_id, device)
|
||||
return True
|
||||
|
||||
|
||||
def load_config(path, hass, consider_home, home_range):
|
||||
""" Load devices from YAML config file. """
|
||||
if not os.path.isfile(path):
|
||||
return []
|
||||
return [
|
||||
Device(hass, consider_home, home_range, device.get('track', False),
|
||||
str(dev_id).lower(), str(device.get('mac')).upper(),
|
||||
device.get('name'), device.get('picture'),
|
||||
device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE))
|
||||
for dev_id, device in load_yaml_config_file(path).items()]
|
||||
|
||||
|
||||
def setup_scanner_platform(hass, config, scanner, see_device):
|
||||
""" Helper method to connect scanner-based platform to device tracker. """
|
||||
interval = util.convert(config.get(CONF_SCAN_INTERVAL), int,
|
||||
DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
# Initial scan of each mac we also tell about host name for config
|
||||
seen = set()
|
||||
|
||||
def device_tracker_scan(now):
|
||||
""" Called when interval matches. """
|
||||
for mac in scanner.scan_devices():
|
||||
if mac in seen:
|
||||
host_name = None
|
||||
else:
|
||||
host_name = scanner.get_device_name(mac)
|
||||
seen.add(mac)
|
||||
see_device(mac=mac, host_name=host_name)
|
||||
|
||||
track_utc_time_change(hass, device_tracker_scan, second=range(0, 60,
|
||||
interval))
|
||||
|
||||
device_tracker_scan(None)
|
||||
|
||||
|
||||
def update_config(path, dev_id, device):
|
||||
""" Add device to YAML config file. """
|
||||
with open(path, 'a') as out:
|
||||
out.write('\n')
|
||||
out.write('{}:\n'.format(device.dev_id))
|
||||
|
||||
for key, value in (('name', device.name), ('mac', device.mac),
|
||||
('picture', device.config_picture),
|
||||
('track', 'yes' if device.track else 'no'),
|
||||
(CONF_AWAY_HIDE,
|
||||
'yes' if device.away_hide else 'no')):
|
||||
out.write(' {}: {}\n'.format(key, '' if value is None else value))
|
||||
191
homeassistant/components/device_tracker/actiontec.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.actiontec
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning an Actiontec MI424WR
|
||||
(Verizon FIOS) router for device presence.
|
||||
|
||||
This device tracker needs telnet to be enabled on the router.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Actiontec tracker you will need to add something like the
|
||||
following to your configuration.yaml file. If you experience disconnects
|
||||
you can modify the home_interval variable.
|
||||
|
||||
device_tracker:
|
||||
platform: actiontec
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
# optional:
|
||||
home_interval: 10
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
|
||||
home_interval
|
||||
*Optional
|
||||
If the home_interval is set then the component will not let a device
|
||||
be AWAY if it has been HOME in the last home_interval minutes. This is
|
||||
in addition to the 3 minute wait built into the device_tracker component.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from collections import namedtuple
|
||||
import re
|
||||
import threading
|
||||
import telnetlib
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle, convert
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
# interval in minutes to exclude devices from a scan while they are home
|
||||
CONF_HOME_INTERVAL = "home_interval"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_LEASES_REGEX = re.compile(
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})' +
|
||||
r'\smac:\s(?P<mac>([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns an Actiontec scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = ActiontecDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
Device = namedtuple("Device", ["mac", "ip", "last_update"])
|
||||
|
||||
|
||||
class ActiontecDeviceScanner(object):
|
||||
"""
|
||||
This class queries a an actiontec router for connected devices.
|
||||
Adapted from DD-WRT scanner.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0)
|
||||
self.home_interval = timedelta(minutes=minutes)
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = []
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_actiontec_data()
|
||||
self.success_init = data is not None
|
||||
_LOGGER.info("actiontec scanner initialized")
|
||||
if self.home_interval:
|
||||
_LOGGER.info("home_interval set to: %s", self.home_interval)
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client.mac for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client.mac == device:
|
||||
return client.ip
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the Actiontec MI424WR router is up
|
||||
to date. Returns boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.info("Scanning")
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
exclude_targets = set()
|
||||
exclude_target_list = []
|
||||
now = dt_util.now()
|
||||
if self.home_interval:
|
||||
for host in self.last_results:
|
||||
if host.last_update + self.home_interval > now:
|
||||
exclude_targets.add(host)
|
||||
if len(exclude_targets) > 0:
|
||||
exclude_target_list = [t.ip for t in exclude_targets]
|
||||
|
||||
actiontec_data = self.get_actiontec_data()
|
||||
if not actiontec_data:
|
||||
return False
|
||||
self.last_results = []
|
||||
for client in exclude_target_list:
|
||||
if client in actiontec_data:
|
||||
actiontec_data.pop(client)
|
||||
for name, data in actiontec_data.items():
|
||||
device = Device(data['mac'], name, now)
|
||||
self.last_results.append(device)
|
||||
self.last_results.extend(exclude_targets)
|
||||
_LOGGER.info("actiontec scan successful")
|
||||
return True
|
||||
|
||||
def get_actiontec_data(self):
|
||||
""" Retrieve data from Actiontec MI424WR and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'Username: ')
|
||||
telnet.write((self.username + '\n').encode('ascii'))
|
||||
telnet.read_until(b'Password: ')
|
||||
telnet.write((self.password + '\n').encode('ascii'))
|
||||
prompt = telnet.read_until(
|
||||
b'Wireless Broadband Router> ').split(b'\n')[-1]
|
||||
telnet.write('firewall mac_cache_dump\n'.encode('ascii'))
|
||||
telnet.write('\n'.encode('ascii'))
|
||||
telnet.read_until(prompt)
|
||||
leases_result = telnet.read_until(prompt).split(b'\n')[1:-1]
|
||||
telnet.write('exit\n'.encode('ascii'))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
" is telnet enabled?")
|
||||
return None
|
||||
|
||||
devices = {}
|
||||
for lease in leases_result:
|
||||
match = _LEASES_REGEX.search(lease.decode('utf-8'))
|
||||
if match is not None:
|
||||
devices[match.group('ip')] = {
|
||||
'ip': match.group('ip'),
|
||||
'mac': match.group('mac').upper()
|
||||
}
|
||||
return devices
|
||||
148
homeassistant/components/device_tracker/aruba.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.aruba
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a Aruba Access Point for device
|
||||
presence.
|
||||
|
||||
This device tracker needs telnet to be enabled on the router.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Aruba tracker you will need to add something like the following
|
||||
to your configuration.yaml file. You also need to enable Telnet in the
|
||||
configuration page of your router.
|
||||
|
||||
device_tracker:
|
||||
platform: aruba
|
||||
host: YOUR_ACCESS_POINT_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import telnetlib
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEVICES_REGEX = re.compile(
|
||||
r'(?P<name>([^\s]+))\s+' +
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' +
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Aruba scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = ArubaDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class ArubaDeviceScanner(object):
|
||||
""" This class queries a Aruba Acces Point for connected devices. """
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_aruba_data()
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device IDs.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client['mac'] == device:
|
||||
return client['name']
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the Aruba Access Point is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
data = self.get_aruba_data()
|
||||
if not data:
|
||||
return False
|
||||
|
||||
self.last_results = data.values()
|
||||
return True
|
||||
|
||||
def get_aruba_data(self):
|
||||
""" Retrieve data from Aruba Access Point and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'User: ')
|
||||
telnet.write((self.username + '\r\n').encode('ascii'))
|
||||
telnet.read_until(b'Password: ')
|
||||
telnet.write((self.password + '\r\n').encode('ascii'))
|
||||
telnet.read_until(b'#')
|
||||
telnet.write(('show clients\r\n').encode('ascii'))
|
||||
devices_result = telnet.read_until(b'#').split(b'\r\n')
|
||||
telnet.write('exit\r\n'.encode('ascii'))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
" is telnet enabled?")
|
||||
return
|
||||
|
||||
devices = {}
|
||||
for device in devices_result:
|
||||
match = _DEVICES_REGEX.search(device.decode('utf-8'))
|
||||
if match:
|
||||
devices[match.group('ip')] = {
|
||||
'ip': match.group('ip'),
|
||||
'mac': match.group('mac').upper(),
|
||||
'name': match.group('name')
|
||||
}
|
||||
return devices
|
||||
179
homeassistant/components/device_tracker/asuswrt.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.asuswrt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a ASUSWRT router for device
|
||||
presence.
|
||||
|
||||
This device tracker needs telnet to be enabled on the router.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the ASUSWRT tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: asuswrt
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import telnetlib
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_LEASES_REGEX = re.compile(
|
||||
r'\w+\s' +
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
|
||||
r'(?P<host>([^\s]+))')
|
||||
|
||||
_IP_NEIGH_REGEX = re.compile(
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
|
||||
r'\w+\s' +
|
||||
r'\w+\s' +
|
||||
r'(\w+\s(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' +
|
||||
r'(?P<status>(\w+))')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns an ASUS-WRT scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = AsusWrtDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class AsusWrtDeviceScanner(object):
|
||||
"""
|
||||
This class queries a router running ASUSWRT firmware
|
||||
for connected devices. Adapted from DD-WRT scanner.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_asuswrt_data()
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device IDs.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client['mac'] == device:
|
||||
return client['host']
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the ASUSWRT router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Checking ARP")
|
||||
data = self.get_asuswrt_data()
|
||||
if not data:
|
||||
return False
|
||||
|
||||
active_clients = [client for client in data.values() if
|
||||
client['status'] == 'REACHABLE' or
|
||||
client['status'] == 'DELAY' or
|
||||
client['status'] == 'STALE']
|
||||
self.last_results = active_clients
|
||||
return True
|
||||
|
||||
def get_asuswrt_data(self):
|
||||
""" Retrieve data from ASUSWRT and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'login: ')
|
||||
telnet.write((self.username + '\n').encode('ascii'))
|
||||
telnet.read_until(b'Password: ')
|
||||
telnet.write((self.password + '\n').encode('ascii'))
|
||||
prompt_string = telnet.read_until(b'#').split(b'\n')[-1]
|
||||
telnet.write('ip neigh\n'.encode('ascii'))
|
||||
neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1]
|
||||
telnet.write('cat /var/lib/misc/dnsmasq.leases\n'.encode('ascii'))
|
||||
leases_result = telnet.read_until(prompt_string).split(b'\n')[1:-1]
|
||||
telnet.write('exit\n'.encode('ascii'))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
" is telnet enabled?")
|
||||
return
|
||||
|
||||
devices = {}
|
||||
for lease in leases_result:
|
||||
match = _LEASES_REGEX.search(lease.decode('utf-8'))
|
||||
|
||||
# For leases where the client doesn't set a hostname, ensure
|
||||
# it is blank and not '*', which breaks the entity_id down
|
||||
# the line
|
||||
host = match.group('host')
|
||||
if host == '*':
|
||||
host = ''
|
||||
|
||||
devices[match.group('ip')] = {
|
||||
'host': host,
|
||||
'status': '',
|
||||
'ip': match.group('ip'),
|
||||
'mac': match.group('mac').upper(),
|
||||
}
|
||||
|
||||
for neighbor in neighbors:
|
||||
match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8'))
|
||||
if match.group('ip') in devices:
|
||||
devices[match.group('ip')]['status'] = match.group('status')
|
||||
return devices
|
||||
194
homeassistant/components/device_tracker/ddwrt.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.ddwrt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a DD-WRT router for device
|
||||
presence.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the DD-WRT tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: ddwrt
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a DD-WRT scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = DdWrtDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class DdWrtDeviceScanner(object):
|
||||
"""
|
||||
This class queries a wireless router running DD-WRT firmware
|
||||
for connected devices. Adapted from Tomato scanner.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
self.mac2name = None
|
||||
|
||||
# Test the router is accessible
|
||||
url = 'http://{}/Status_Wireless.live.asp'.format(self.host)
|
||||
data = self.get_ddwrt_data(url)
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
return self.last_results
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
with self.lock:
|
||||
# if not initialised and not already scanned and not found
|
||||
if self.mac2name is None or device not in self.mac2name:
|
||||
url = 'http://{}/Status_Lan.live.asp'.format(self.host)
|
||||
data = self.get_ddwrt_data(url)
|
||||
|
||||
if not data:
|
||||
return
|
||||
|
||||
dhcp_leases = data.get('dhcp_leases', None)
|
||||
if dhcp_leases:
|
||||
# remove leading and trailing single quotes
|
||||
cleaned_str = dhcp_leases.strip().strip('"')
|
||||
elements = cleaned_str.split('","')
|
||||
num_clients = int(len(elements)/5)
|
||||
self.mac2name = {}
|
||||
for idx in range(0, num_clients):
|
||||
# this is stupid but the data is a single array
|
||||
# every 5 elements represents one hosts, the MAC
|
||||
# is the third element and the name is the first
|
||||
mac_index = (idx * 5) + 2
|
||||
if mac_index < len(elements):
|
||||
mac = elements[mac_index]
|
||||
self.mac2name[mac] = elements[idx * 5]
|
||||
|
||||
return self.mac2name.get(device, None)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the DD-WRT router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Checking ARP")
|
||||
|
||||
url = 'http://{}/Status_Wireless.live.asp'.format(self.host)
|
||||
data = self.get_ddwrt_data(url)
|
||||
|
||||
if not data:
|
||||
return False
|
||||
|
||||
if data:
|
||||
self.last_results = []
|
||||
active_clients = data.get('active_wireless', None)
|
||||
if active_clients:
|
||||
# This is really lame, instead of using JSON the DD-WRT UI
|
||||
# uses its own data format for some reason and then
|
||||
# regex's out values so I guess I have to do the same,
|
||||
# LAME!!!
|
||||
|
||||
# remove leading and trailing single quotes
|
||||
clean_str = active_clients.strip().strip("'")
|
||||
elements = clean_str.split("','")
|
||||
|
||||
num_clients = int(len(elements)/9)
|
||||
for idx in range(0, num_clients):
|
||||
# get every 9th element which is the MAC address
|
||||
index = idx * 9
|
||||
if index < len(elements):
|
||||
self.last_results.append(elements[index])
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_ddwrt_data(self, url):
|
||||
""" Retrieve data from DD-WRT and return parsed result. """
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
auth=(self.username, self.password),
|
||||
timeout=4)
|
||||
except requests.exceptions.Timeout:
|
||||
_LOGGER.exception("Connection to the router timed out")
|
||||
return
|
||||
if response.status_code == 200:
|
||||
return _parse_ddwrt_response(response.text)
|
||||
elif response.status_code == 401:
|
||||
# Authentication error
|
||||
_LOGGER.exception(
|
||||
"Failed to authenticate, "
|
||||
"please check your username and password")
|
||||
return
|
||||
else:
|
||||
_LOGGER.error("Invalid response from ddwrt: %s", response)
|
||||
|
||||
|
||||
def _parse_ddwrt_response(data_str):
|
||||
""" Parse the DD-WRT data format. """
|
||||
return {
|
||||
key: val for key, val in _DDWRT_DATA_REGEX
|
||||
.findall(data_str)}
|
||||
50
homeassistant/components/device_tracker/demo.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Demo platform for the device tracker.
|
||||
|
||||
device_tracker:
|
||||
platform: demo
|
||||
"""
|
||||
import random
|
||||
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
""" Set up a demo tracker. """
|
||||
|
||||
def offset():
|
||||
""" Return random offset. """
|
||||
return (random.randrange(500, 2000)) / 2e5 * random.choice((-1, 1))
|
||||
|
||||
def random_see(dev_id, name):
|
||||
""" Randomize a sighting. """
|
||||
see(
|
||||
dev_id=dev_id,
|
||||
host_name=name,
|
||||
gps=(hass.config.latitude + offset(),
|
||||
hass.config.longitude + offset()),
|
||||
gps_accuracy=random.randrange(50, 150),
|
||||
battery=random.randrange(10, 90)
|
||||
)
|
||||
|
||||
def observe(call=None):
|
||||
""" Observe three entities. """
|
||||
random_see('demo_paulus', 'Paulus')
|
||||
random_see('demo_anne_therese', 'Anne Therese')
|
||||
|
||||
observe()
|
||||
|
||||
see(
|
||||
dev_id='demo_home_boy',
|
||||
host_name='Home Boy',
|
||||
gps=[hass.config.latitude - 0.00002, hass.config.longitude + 0.00002],
|
||||
gps_accuracy=20,
|
||||
battery=53
|
||||
)
|
||||
|
||||
hass.services.register(DOMAIN, 'demo', observe)
|
||||
|
||||
return True
|
||||
186
homeassistant/components/device_tracker/luci.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.luci
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a OpenWRT router for device
|
||||
presence.
|
||||
|
||||
It's required that the luci RPC package is installed on the OpenWRT router:
|
||||
# opkg install luci-mod-rpc
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Luci tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: luci
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Luci scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = LuciDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class LuciDeviceScanner(object):
|
||||
"""
|
||||
This class queries a wireless router running OpenWrt firmware
|
||||
for connected devices. Adapted from Tomato scanner.
|
||||
|
||||
# opkg install luci-mod-rpc
|
||||
for this to work on the router.
|
||||
|
||||
The API is described here:
|
||||
http://luci.subsignal.org/trac/wiki/Documentation/JsonRpcHowTo
|
||||
|
||||
(Currently, we do only wifi iwscan, and no DHCP lease access.)
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
host = config[CONF_HOST]
|
||||
username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
|
||||
|
||||
self.parse_api_pattern = re.compile(r"(?P<param>\w*) = (?P<value>.*);")
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
self.token = _get_token(host, username, password)
|
||||
self.host = host
|
||||
|
||||
self.mac2name = None
|
||||
self.success_init = self.token is not None
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
return self.last_results
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
with self.lock:
|
||||
if self.mac2name is None:
|
||||
url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host)
|
||||
result = _req_json_rpc(url, 'get_all', 'dhcp',
|
||||
params={'auth': self.token})
|
||||
if result:
|
||||
hosts = [x for x in result.values()
|
||||
if x['.type'] == 'host' and
|
||||
'mac' in x and 'name' in x]
|
||||
mac2name_list = [
|
||||
(x['mac'].upper(), x['name']) for x in hosts]
|
||||
self.mac2name = dict(mac2name_list)
|
||||
else:
|
||||
# Error, handled in the _req_json_rpc
|
||||
return
|
||||
return self.mac2name.get(device.upper(), None)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the Luci router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Checking ARP")
|
||||
|
||||
url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host)
|
||||
result = _req_json_rpc(url, 'net.arptable',
|
||||
params={'auth': self.token})
|
||||
if result:
|
||||
self.last_results = []
|
||||
for device_entry in result:
|
||||
# Check if the Flags for each device contain
|
||||
# NUD_REACHABLE and if so, add it to last_results
|
||||
if int(device_entry['Flags'], 16) & 0x2:
|
||||
self.last_results.append(device_entry['HW address'])
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _req_json_rpc(url, method, *args, **kwargs):
|
||||
""" Perform one JSON RPC operation. """
|
||||
data = json.dumps({'method': method, 'params': args})
|
||||
try:
|
||||
res = requests.post(url, data=data, timeout=5, **kwargs)
|
||||
except requests.exceptions.Timeout:
|
||||
_LOGGER.exception("Connection to the router timed out")
|
||||
return
|
||||
if res.status_code == 200:
|
||||
try:
|
||||
result = res.json()
|
||||
except ValueError:
|
||||
# If json decoder could not parse the response
|
||||
_LOGGER.exception("Failed to parse response from luci")
|
||||
return
|
||||
try:
|
||||
return result['result']
|
||||
except KeyError:
|
||||
_LOGGER.exception("No result in response from luci")
|
||||
return
|
||||
elif res.status_code == 401:
|
||||
# Authentication error
|
||||
_LOGGER.exception(
|
||||
"Failed to authenticate, "
|
||||
"please check your username and password")
|
||||
return
|
||||
else:
|
||||
_LOGGER.error("Invalid response from luci: %s", res)
|
||||
|
||||
|
||||
def _get_token(host, username, password):
|
||||
""" Get authentication token for the given host+username+password. """
|
||||
url = 'http://{}/cgi-bin/luci/rpc/auth'.format(host)
|
||||
return _req_json_rpc(url, 'login', username, password)
|
||||
48
homeassistant/components/device_tracker/mqtt.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
MQTT platform for the device tracker.
|
||||
|
||||
device_tracker:
|
||||
platform: mqtt
|
||||
qos: 1
|
||||
devices:
|
||||
paulus_oneplus: /location/paulus
|
||||
annetherese_n4: /location/annetherese
|
||||
"""
|
||||
import logging
|
||||
from homeassistant import util
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
CONF_QOS = 'qos'
|
||||
CONF_DEVICES = 'devices'
|
||||
|
||||
DEFAULT_QOS = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
""" Set up a MQTT tracker. """
|
||||
devices = config.get(CONF_DEVICES)
|
||||
qos = util.convert(config.get(CONF_QOS), int, DEFAULT_QOS)
|
||||
|
||||
if not isinstance(devices, dict):
|
||||
_LOGGER.error('Expected %s to be a dict, found %s', CONF_DEVICES,
|
||||
devices)
|
||||
return False
|
||||
|
||||
dev_id_lookup = {}
|
||||
|
||||
def device_tracker_message_received(topic, payload, qos):
|
||||
""" MQTT message received. """
|
||||
see(dev_id=dev_id_lookup[topic], location_name=payload)
|
||||
|
||||
for dev_id, topic in devices.items():
|
||||
dev_id_lookup[topic] = dev_id
|
||||
mqtt.subscribe(hass, topic, device_tracker_message_received, qos)
|
||||
|
||||
return True
|
||||
118
homeassistant/components/device_tracker/netgear.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.netgear
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a Netgear router for device
|
||||
presence.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Netgear tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: netgear
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import threading
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['pynetgear==0.3']
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Netgear scanner. """
|
||||
info = config[DOMAIN]
|
||||
host = info.get(CONF_HOST)
|
||||
username = info.get(CONF_USERNAME)
|
||||
password = info.get(CONF_PASSWORD)
|
||||
|
||||
if password is not None and host is None:
|
||||
_LOGGER.warning('Found username or password but no host')
|
||||
return None
|
||||
|
||||
scanner = NetgearDeviceScanner(host, username, password)
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class NetgearDeviceScanner(object):
|
||||
""" This class queries a Netgear wireless router using the SOAP-API. """
|
||||
|
||||
def __init__(self, host, username, password):
|
||||
import pynetgear
|
||||
|
||||
self.last_results = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
if host is None:
|
||||
self._api = pynetgear.Netgear()
|
||||
elif username is None:
|
||||
self._api = pynetgear.Netgear(password, host)
|
||||
else:
|
||||
self._api = pynetgear.Netgear(password, host, username)
|
||||
|
||||
_LOGGER.info("Logging in")
|
||||
|
||||
results = self._api.get_attached_devices()
|
||||
|
||||
self.success_init = results is not None
|
||||
|
||||
if self.success_init:
|
||||
self.last_results = results
|
||||
else:
|
||||
_LOGGER.error("Failed to Login")
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
self._update_info()
|
||||
|
||||
return (device.mac for device in self.last_results)
|
||||
|
||||
def get_device_name(self, mac):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
try:
|
||||
return next(device.name for device in self.last_results
|
||||
if device.mac == mac)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Retrieves latest information from the Netgear router.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Scanning")
|
||||
|
||||
self.last_results = self._api.get_attached_devices() or []
|
||||
150
homeassistant/components/device_tracker/nmap_tracker.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.nmap
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a network with nmap.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the nmap tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: nmap_tracker
|
||||
hosts: 192.168.1.1/24
|
||||
|
||||
Variables:
|
||||
|
||||
hosts
|
||||
*Required
|
||||
The IP addresses to scan in the network-prefix notation (192.168.1.1/24) or
|
||||
the range notation (192.168.1.1-255).
|
||||
|
||||
home_interval
|
||||
*Optional
|
||||
Number of minutes it will not scan devices that it found in previous results.
|
||||
This is to save battery.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from collections import namedtuple
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import CONF_HOSTS
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle, convert
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# interval in minutes to exclude devices from a scan while they are home
|
||||
CONF_HOME_INTERVAL = "home_interval"
|
||||
|
||||
REQUIREMENTS = ['python-nmap==0.4.3']
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Nmap scanner. """
|
||||
if not validate_config(config, {DOMAIN: [CONF_HOSTS]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = NmapDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
Device = namedtuple("Device", ["mac", "name", "ip", "last_update"])
|
||||
|
||||
|
||||
def _arp(ip_address):
|
||||
""" Get the MAC address for a given IP. """
|
||||
cmd = ['arp', '-n', ip_address]
|
||||
arp = subprocess.Popen(cmd, stdout=subprocess.PIPE)
|
||||
out, _ = arp.communicate()
|
||||
match = re.search(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})', str(out))
|
||||
if match:
|
||||
return match.group(0)
|
||||
_LOGGER.info("No MAC address found for %s", ip_address)
|
||||
return None
|
||||
|
||||
|
||||
class NmapDeviceScanner(object):
|
||||
""" This class scans for devices using nmap. """
|
||||
|
||||
def __init__(self, config):
|
||||
self.last_results = []
|
||||
|
||||
self.hosts = config[CONF_HOSTS]
|
||||
minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0)
|
||||
self.home_interval = timedelta(minutes=minutes)
|
||||
|
||||
self.success_init = self._update_info()
|
||||
_LOGGER.info("nmap scanner initialized")
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
return [device.mac for device in self.last_results]
|
||||
|
||||
def get_device_name(self, mac):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
filter_named = [device.name for device in self.last_results
|
||||
if device.mac == mac]
|
||||
|
||||
if filter_named:
|
||||
return filter_named[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Scans the network for devices.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.info("Scanning")
|
||||
|
||||
from nmap import PortScanner, PortScannerError
|
||||
scanner = PortScanner()
|
||||
|
||||
options = "-F --host-timeout 5"
|
||||
exclude_targets = set()
|
||||
if self.home_interval:
|
||||
now = dt_util.now()
|
||||
for host in self.last_results:
|
||||
if host.last_update + self.home_interval > now:
|
||||
exclude_targets.add(host)
|
||||
if len(exclude_targets) > 0:
|
||||
target_list = [t.ip for t in exclude_targets]
|
||||
options += " --exclude {}".format(",".join(target_list))
|
||||
|
||||
try:
|
||||
result = scanner.scan(hosts=self.hosts, arguments=options)
|
||||
except PortScannerError:
|
||||
return False
|
||||
|
||||
now = dt_util.now()
|
||||
self.last_results = []
|
||||
for ipv4, info in result['scan'].items():
|
||||
if info['status']['state'] != 'up':
|
||||
continue
|
||||
name = info['hostnames'][0] if info['hostnames'] else ipv4
|
||||
# Mac address only returned if nmap ran as root
|
||||
mac = info['addresses'].get('mac') or _arp(ipv4)
|
||||
if mac is None:
|
||||
continue
|
||||
device = Device(mac.upper(), name, ipv4, now)
|
||||
self.last_results.append(device)
|
||||
self.last_results.extend(exclude_targets)
|
||||
|
||||
_LOGGER.info("nmap scan successful")
|
||||
return True
|
||||
54
homeassistant/components/device_tracker/owntracks.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.owntracks
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
OwnTracks platform for the device tracker.
|
||||
|
||||
device_tracker:
|
||||
platform: owntracks
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
LOCATION_TOPIC = 'owntracks/+/+'
|
||||
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
""" Set up a OwnTracksks tracker. """
|
||||
|
||||
def owntracks_location_update(topic, payload, qos):
|
||||
""" MQTT message received. """
|
||||
|
||||
# Docs on available data:
|
||||
# http://owntracks.org/booklet/tech/json/#_typelocation
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except ValueError:
|
||||
# If invalid JSON
|
||||
logging.getLogger(__name__).error(
|
||||
'Unable to parse payload as JSON: %s', payload)
|
||||
return
|
||||
|
||||
if not isinstance(data, dict) or data.get('_type') != 'location':
|
||||
return
|
||||
|
||||
parts = topic.split('/')
|
||||
kwargs = {
|
||||
'dev_id': '{}_{}'.format(parts[1], parts[2]),
|
||||
'host_name': parts[1],
|
||||
'gps': (data['lat'], data['lon']),
|
||||
}
|
||||
if 'acc' in data:
|
||||
kwargs['gps_accuracy'] = data['acc']
|
||||
if 'batt' in data:
|
||||
kwargs['battery'] = data['batt']
|
||||
|
||||
see(**kwargs)
|
||||
|
||||
mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1)
|
||||
|
||||
return True
|
||||
160
homeassistant/components/device_tracker/thomson.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.thomson
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a THOMSON router for device
|
||||
presence.
|
||||
|
||||
This device tracker needs telnet to be enabled on the router.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the THOMSON tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: thomson
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import telnetlib
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEVICES_REGEX = re.compile(
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' +
|
||||
r'(?P<status>([^\s]+))\s+' +
|
||||
r'(?P<type>([^\s]+))\s+' +
|
||||
r'(?P<intf>([^\s]+))\s+' +
|
||||
r'(?P<hwintf>([^\s]+))\s+' +
|
||||
r'(?P<host>([^\s]+))')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a THOMSON scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = ThomsonDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class ThomsonDeviceScanner(object):
|
||||
"""
|
||||
This class queries a router running THOMSON firmware
|
||||
for connected devices. Adapted from ASUSWRT scanner.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_thomson_data()
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device
|
||||
or None if we don't know. """
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client['mac'] == device:
|
||||
return client['host']
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the THOMSON router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Checking ARP")
|
||||
data = self.get_thomson_data()
|
||||
if not data:
|
||||
return False
|
||||
|
||||
# flag C stands for CONNECTED
|
||||
active_clients = [client for client in data.values() if
|
||||
client['status'].find('C') != -1]
|
||||
self.last_results = active_clients
|
||||
return True
|
||||
|
||||
def get_thomson_data(self):
|
||||
""" Retrieve data from THOMSON and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'Username : ')
|
||||
telnet.write((self.username + '\r\n').encode('ascii'))
|
||||
telnet.read_until(b'Password : ')
|
||||
telnet.write((self.password + '\r\n').encode('ascii'))
|
||||
telnet.read_until(b'=>')
|
||||
telnet.write(('hostmgr list\r\n').encode('ascii'))
|
||||
devices_result = telnet.read_until(b'=>').split(b'\r\n')
|
||||
telnet.write('exit\r\n'.encode('ascii'))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
" is telnet enabled?")
|
||||
return
|
||||
|
||||
devices = {}
|
||||
for device in devices_result:
|
||||
match = _DEVICES_REGEX.search(device.decode('utf-8'))
|
||||
if match:
|
||||
devices[match.group('ip')] = {
|
||||
'ip': match.group('ip'),
|
||||
'mac': match.group('mac').upper(),
|
||||
'host': match.group('host'),
|
||||
'status': match.group('status')
|
||||
}
|
||||
return devices
|
||||
173
homeassistant/components/device_tracker/tomato.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.tomato
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a Tomato router for device
|
||||
presence.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Tomato tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: tomato
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
http_id: ABCDEFG
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
|
||||
http_id
|
||||
*Required
|
||||
The value can be obtained by logging in to the Tomato admin interface and
|
||||
search for http_id in the page source code.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
CONF_HTTP_ID = "http_id"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Tomato scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME,
|
||||
CONF_PASSWORD, CONF_HTTP_ID]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
return TomatoDeviceScanner(config[DOMAIN])
|
||||
|
||||
|
||||
class TomatoDeviceScanner(object):
|
||||
""" This class queries a wireless router running Tomato firmware
|
||||
for connected devices.
|
||||
|
||||
A description of the Tomato API can be found on
|
||||
http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
host, http_id = config[CONF_HOST], config[CONF_HTTP_ID]
|
||||
username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
|
||||
|
||||
self.req = requests.Request('POST',
|
||||
'http://{}/update.cgi'.format(host),
|
||||
data={'_http_id': http_id,
|
||||
'exec': 'devlist'},
|
||||
auth=requests.auth.HTTPBasicAuth(
|
||||
username, password)).prepare()
|
||||
|
||||
self.parse_api_pattern = re.compile(r"(?P<param>\w*) = (?P<value>.*);")
|
||||
|
||||
self.logger = logging.getLogger("{}.{}".format(__name__, "Tomato"))
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {"wldev": [], "dhcpd_lease": []}
|
||||
|
||||
self.success_init = self._update_tomato_info()
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
|
||||
self._update_tomato_info()
|
||||
|
||||
return [item[1] for item in self.last_results['wldev']]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
filter_named = [item[0] for item in self.last_results['dhcpd_lease']
|
||||
if item[2] == device]
|
||||
|
||||
if not filter_named or not filter_named[0]:
|
||||
return None
|
||||
else:
|
||||
return filter_named[0]
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_tomato_info(self):
|
||||
""" Ensures the information from the Tomato router is up to date.
|
||||
Returns boolean if scanning successful. """
|
||||
|
||||
with self.lock:
|
||||
self.logger.info("Scanning")
|
||||
|
||||
try:
|
||||
response = requests.Session().send(self.req, timeout=3)
|
||||
|
||||
# Calling and parsing the Tomato api here. We only need the
|
||||
# wldev and dhcpd_lease values. For API description see:
|
||||
# http://paulusschoutsen.nl/
|
||||
# blog/2013/10/tomato-api-documentation/
|
||||
if response.status_code == 200:
|
||||
|
||||
for param, value in \
|
||||
self.parse_api_pattern.findall(response.text):
|
||||
|
||||
if param == 'wldev' or param == 'dhcpd_lease':
|
||||
self.last_results[param] = \
|
||||
json.loads(value.replace("'", '"'))
|
||||
|
||||
return True
|
||||
|
||||
elif response.status_code == 401:
|
||||
# Authentication error
|
||||
self.logger.exception((
|
||||
"Failed to authenticate, "
|
||||
"please check your username and password"))
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
# We get this if we could not connect to the router or
|
||||
# an invalid http_id was supplied
|
||||
self.logger.exception((
|
||||
"Failed to connect to the router"
|
||||
" or invalid http_id supplied"))
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
# We get this if we could not connect to the router or
|
||||
# an invalid http_id was supplied
|
||||
self.logger.exception(
|
||||
"Connection to the router timed out")
|
||||
|
||||
return False
|
||||
|
||||
except ValueError:
|
||||
# If json decoder could not parse the response
|
||||
self.logger.exception(
|
||||
"Failed to parse response from router")
|
||||
|
||||
return False
|
||||
189
homeassistant/components/device_tracker/tplink.py
Executable file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.tplink
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a TP-Link router for device
|
||||
presence.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the TP-Link tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: tplink
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a TP-Link scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = Tplink2DeviceScanner(config[DOMAIN])
|
||||
|
||||
if not scanner.success_init:
|
||||
scanner = TplinkDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class TplinkDeviceScanner(object):
|
||||
"""
|
||||
This class queries a wireless router running TP-Link firmware
|
||||
for connected devices.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
host = config[CONF_HOST]
|
||||
username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
|
||||
|
||||
self.parse_macs = re.compile('[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-' +
|
||||
'[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}')
|
||||
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
self.last_results = {}
|
||||
self.lock = threading.Lock()
|
||||
self.success_init = self._update_info()
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
return self.last_results
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def get_device_name(self, device):
|
||||
"""
|
||||
The TP-Link firmware doesn't save the name of the wireless device.
|
||||
"""
|
||||
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the TP-Link router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Loading wireless clients...")
|
||||
|
||||
url = 'http://{}/userRpm/WlanStationRpm.htm'.format(self.host)
|
||||
referer = 'http://{}'.format(self.host)
|
||||
page = requests.get(url, auth=(self.username, self.password),
|
||||
headers={'referer': referer})
|
||||
|
||||
result = self.parse_macs.findall(page.text)
|
||||
|
||||
if result:
|
||||
self.last_results = [mac.replace("-", ":") for mac in result]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Tplink2DeviceScanner(TplinkDeviceScanner):
|
||||
"""
|
||||
This class queries a wireless router running newer version of TP-Link
|
||||
firmware for connected devices.
|
||||
"""
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return self.last_results.keys()
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def get_device_name(self, device):
|
||||
"""
|
||||
The TP-Link firmware doesn't save the name of the wireless device.
|
||||
"""
|
||||
|
||||
return self.last_results.get(device)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the TP-Link router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Loading wireless clients...")
|
||||
|
||||
url = 'http://{}/data/map_access_wireless_client_grid.json'\
|
||||
.format(self.host)
|
||||
referer = 'http://{}'.format(self.host)
|
||||
|
||||
# Router uses Authorization cookie instead of header
|
||||
# Let's create the cookie
|
||||
username_password = '{}:{}'.format(self.username, self.password)
|
||||
b64_encoded_username_password = base64.b64encode(
|
||||
username_password.encode('ascii')
|
||||
).decode('ascii')
|
||||
cookie = 'Authorization=Basic {}'\
|
||||
.format(b64_encoded_username_password)
|
||||
|
||||
response = requests.post(url, headers={'referer': referer,
|
||||
'cookie': cookie})
|
||||
|
||||
try:
|
||||
result = response.json().get('data')
|
||||
except ValueError:
|
||||
_LOGGER.error("Router didn't respond with JSON. "
|
||||
"Check if credentials are correct.")
|
||||
return False
|
||||
|
||||
if result:
|
||||
self.last_results = {
|
||||
device['mac_addr'].replace('-', ':'): device['name']
|
||||
for device in result
|
||||
}
|
||||
return True
|
||||
|
||||
return False
|
||||
99
homeassistant/components/discovery.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
homeassistant.components.discovery
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Starts a service to scan in intervals for new devices.
|
||||
|
||||
Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered.
|
||||
|
||||
Knows which components handle certain types, will make sure they are
|
||||
loaded before the EVENT_PLATFORM_DISCOVERED is fired.
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from homeassistant import bootstrap
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, EVENT_PLATFORM_DISCOVERED,
|
||||
ATTR_SERVICE, ATTR_DISCOVERED)
|
||||
|
||||
DOMAIN = "discovery"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['netdisco==0.4.2']
|
||||
|
||||
SCAN_INTERVAL = 300 # seconds
|
||||
|
||||
SERVICE_WEMO = 'belkin_wemo'
|
||||
SERVICE_HUE = 'philips_hue'
|
||||
SERVICE_CAST = 'google_cast'
|
||||
SERVICE_NETGEAR = 'netgear_router'
|
||||
SERVICE_SONOS = 'sonos'
|
||||
|
||||
SERVICE_HANDLERS = {
|
||||
SERVICE_WEMO: "switch",
|
||||
SERVICE_CAST: "media_player",
|
||||
SERVICE_HUE: "light",
|
||||
SERVICE_NETGEAR: 'device_tracker',
|
||||
SERVICE_SONOS: 'media_player',
|
||||
}
|
||||
|
||||
|
||||
def listen(hass, service, callback):
|
||||
"""
|
||||
Setup listener for discovery of specific service.
|
||||
Service can be a string or a list/tuple.
|
||||
"""
|
||||
|
||||
if isinstance(service, str):
|
||||
service = (service,)
|
||||
else:
|
||||
service = tuple(service)
|
||||
|
||||
def discovery_event_listener(event):
|
||||
""" Listens for discovery events. """
|
||||
if event.data[ATTR_SERVICE] in service:
|
||||
callback(event.data[ATTR_SERVICE], event.data[ATTR_DISCOVERED])
|
||||
|
||||
hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Starts a discovery service. """
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from netdisco.service import DiscoveryService
|
||||
|
||||
# Disable zeroconf logging, it spams
|
||||
logging.getLogger('zeroconf').setLevel(logging.CRITICAL)
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
def new_service_listener(service, info):
|
||||
""" Called when a new service is found. """
|
||||
with lock:
|
||||
logger.info("Found new service: %s %s", service, info)
|
||||
|
||||
component = SERVICE_HANDLERS.get(service)
|
||||
|
||||
# We do not know how to handle this service
|
||||
if not component:
|
||||
return
|
||||
|
||||
# This component cannot be setup.
|
||||
if not bootstrap.setup_component(hass, component, config):
|
||||
return
|
||||
|
||||
hass.bus.fire(EVENT_PLATFORM_DISCOVERED, {
|
||||
ATTR_SERVICE: service,
|
||||
ATTR_DISCOVERED: info
|
||||
})
|
||||
|
||||
def start_discovery(event):
|
||||
""" Start discovering. """
|
||||
netdisco = DiscoveryService(SCAN_INTERVAL)
|
||||
netdisco.add_listener(new_service_listener)
|
||||
netdisco.start()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_discovery)
|
||||
|
||||
return True
|
||||
@@ -9,18 +9,22 @@ import logging
|
||||
import re
|
||||
import threading
|
||||
|
||||
import homeassistant.util as util
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import sanitize_filename
|
||||
|
||||
DOMAIN = "downloader"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_DOWNLOAD_FILE = "download_file"
|
||||
|
||||
ATTR_URL = "url"
|
||||
ATTR_SUBDIR = "subdir"
|
||||
|
||||
CONF_DOWNLOAD_DIR = 'download_dir'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def setup(bus, download_path):
|
||||
def setup(hass, config):
|
||||
""" Listens for download events to download files. """
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,18 +37,23 @@ def setup(bus, download_path):
|
||||
|
||||
return False
|
||||
|
||||
if not validate_config(config, {DOMAIN: [CONF_DOWNLOAD_DIR]}, logger):
|
||||
return False
|
||||
|
||||
download_path = config[DOMAIN][CONF_DOWNLOAD_DIR]
|
||||
|
||||
if not os.path.isdir(download_path):
|
||||
|
||||
logger.error(
|
||||
("Download path {} does not exist. File Downloader not active.").
|
||||
format(download_path))
|
||||
"Download path %s does not exist. File Downloader not active.",
|
||||
download_path)
|
||||
|
||||
return False
|
||||
|
||||
def download_file(service):
|
||||
""" Starts thread to download file specified in the url. """
|
||||
|
||||
if not ATTR_URL in service.data:
|
||||
if ATTR_URL not in service.data:
|
||||
logger.error("Service called but 'url' parameter not specified.")
|
||||
return
|
||||
|
||||
@@ -56,11 +65,11 @@ def setup(bus, download_path):
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
if subdir:
|
||||
subdir = util.sanitize_filename(subdir)
|
||||
subdir = sanitize_filename(subdir)
|
||||
|
||||
final_path = None
|
||||
|
||||
req = requests.get(url, stream=True)
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code == 200:
|
||||
filename = None
|
||||
@@ -80,7 +89,7 @@ def setup(bus, download_path):
|
||||
filename = "ha_download"
|
||||
|
||||
# Remove stuff to ruin paths
|
||||
filename = util.sanitize_filename(filename)
|
||||
filename = sanitize_filename(filename)
|
||||
|
||||
# Do we want to download to subdir, create if needed
|
||||
if subdir:
|
||||
@@ -106,19 +115,16 @@ def setup(bus, download_path):
|
||||
|
||||
final_path = "{}_{}.{}".format(path, tries, ext)
|
||||
|
||||
logger.info("{} -> {}".format(
|
||||
url, final_path))
|
||||
logger.info("%s -> %s", url, final_path)
|
||||
|
||||
with open(final_path, 'wb') as fil:
|
||||
for chunk in req.iter_content(1024):
|
||||
fil.write(chunk)
|
||||
|
||||
logger.info("Downloading of {} done".format(
|
||||
url))
|
||||
logger.info("Downloading of %s done", url)
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.exception("ConnectionError occured for {}".
|
||||
format(url))
|
||||
logger.exception("ConnectionError occured for %s", url)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
@@ -126,7 +132,7 @@ def setup(bus, download_path):
|
||||
|
||||
threading.Thread(target=do_download).start()
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_DOWNLOAD_FILE,
|
||||
download_file)
|
||||
hass.services.register(DOMAIN, SERVICE_DOWNLOAD_FILE,
|
||||
download_file)
|
||||
|
||||
return True
|
||||
|
||||
86
homeassistant/components/frontend/__init__.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
homeassistant.components.frontend
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides a frontend for Home Assistant.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
|
||||
from . import version
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import URL_ROOT, HTTP_OK
|
||||
|
||||
DOMAIN = 'frontend'
|
||||
DEPENDENCIES = ['api']
|
||||
|
||||
INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template')
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FRONTEND_URLS = [
|
||||
URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState',
|
||||
'/devEvent']
|
||||
STATES_URL = re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)')
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup serving the frontend. """
|
||||
if 'http' not in hass.config.components:
|
||||
_LOGGER.error('Dependency http is not loaded')
|
||||
return False
|
||||
|
||||
for url in FRONTEND_URLS:
|
||||
hass.http.register_path('GET', url, _handle_get_root, False)
|
||||
|
||||
hass.http.register_path('GET', STATES_URL, _handle_get_root, False)
|
||||
|
||||
# Static files
|
||||
hass.http.register_path(
|
||||
'GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_static, False)
|
||||
hass.http.register_path(
|
||||
'HEAD', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_static, False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_root(handler, path_match, data):
|
||||
""" Renders the debug interface. """
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
handler.end_headers()
|
||||
|
||||
if handler.server.development:
|
||||
app_url = "home-assistant-polymer/src/home-assistant.html"
|
||||
else:
|
||||
app_url = "frontend-{}.html".format(version.VERSION)
|
||||
|
||||
# auto login if no password was set, else check api_password param
|
||||
auth = ('no_password_set' if handler.server.no_password_set
|
||||
else data.get('api_password', ''))
|
||||
|
||||
with open(INDEX_PATH) as template_file:
|
||||
template_html = template_file.read()
|
||||
|
||||
template_html = template_html.replace('{{ app_url }}', app_url)
|
||||
template_html = template_html.replace('{{ auth }}', auth)
|
||||
|
||||
handler.wfile.write(template_html.encode("UTF-8"))
|
||||
|
||||
|
||||
def _handle_get_static(handler, path_match, data):
|
||||
""" Returns a static file for the frontend. """
|
||||
req_file = util.sanitize_path(path_match.group('file'))
|
||||
|
||||
# Strip md5 hash out of frontend filename
|
||||
if re.match(r'^frontend-[A-Za-z0-9]{32}\.html$', req_file):
|
||||
req_file = "frontend.html"
|
||||
|
||||
path = os.path.join(os.path.dirname(__file__), 'www_static', req_file)
|
||||
|
||||
handler.write_file(path)
|
||||
51
homeassistant/components/frontend/index.html.template
Normal file
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Home Assistant</title>
|
||||
|
||||
<link rel='manifest' href='/static/manifest.json' />
|
||||
<link rel='shortcut icon' href='/static/favicon.ico' />
|
||||
<link rel='icon' type='image/png'
|
||||
href='/static/favicon-192x192.png' sizes='192x192'>
|
||||
<link rel='apple-touch-icon' sizes='180x180'
|
||||
href='/static/favicon-apple-180x180.png'>
|
||||
<meta name='apple-mobile-web-app-capable' content='yes'>
|
||||
<meta name='mobile-web-app-capable' content='yes'>
|
||||
<meta name='viewport' content='width=device-width,
|
||||
user-scalable=no' />
|
||||
<meta name='theme-color' content='#03a9f4'>
|
||||
<style>
|
||||
#init {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-webkit-justify-content: center;
|
||||
-webkit-align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-family: 'Roboto', 'Noto', sans-serif;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
#init div {
|
||||
line-height: 34px;
|
||||
margin-bottom: 89px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body fullbleed>
|
||||
<div id='init'>
|
||||
<img src='/static/splash.png' height='230' />
|
||||
<div>Initializing</div>
|
||||
</div>
|
||||
<script src='/static/webcomponents-lite.min.js'></script>
|
||||
<link rel='import' href='/static/{{ app_url }}' />
|
||||
<home-assistant auth='{{ auth }}'></home-assistant>
|
||||
</body>
|
||||
</html>
|
||||
2
homeassistant/components/frontend/version.py
Normal file
@@ -0,0 +1,2 @@
|
||||
""" DO NOT MODIFY. Auto-generated by build_frontend script """
|
||||
VERSION = "c4722afa376379bc4457d54bb9a38cee"
|
||||
BIN
homeassistant/components/frontend/www_static/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
homeassistant/components/frontend/www_static/favicon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
6570
homeassistant/components/frontend/www_static/frontend.html
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 797 B |
14
homeassistant/components/frontend/www_static/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Home Assistant",
|
||||
"short_name": "Assistant",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/static\/favicon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
homeassistant/components/frontend/www_static/splash.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
12
homeassistant/components/frontend/www_static/webcomponents-lite.min.js
vendored
Normal file
@@ -1,59 +1,65 @@
|
||||
"""
|
||||
homeassistant.components.groups
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
homeassistant.components.group
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to group devices that can be turned on or off.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.helpers import generate_entity_id
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.helpers.entity import Entity
|
||||
import homeassistant.util as util
|
||||
from homeassistant.components import (STATE_ON, STATE_OFF,
|
||||
STATE_HOME, STATE_NOT_HOME,
|
||||
ATTR_ENTITY_ID)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, STATE_ON, STATE_OFF,
|
||||
STATE_HOME, STATE_NOT_HOME, STATE_OPEN, STATE_CLOSED,
|
||||
STATE_UNKNOWN)
|
||||
|
||||
DOMAIN = "group"
|
||||
DEPENDENCIES = []
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
_GROUP_TYPES = {
|
||||
"on_off": (STATE_ON, STATE_OFF),
|
||||
"home_not_home": (STATE_HOME, STATE_NOT_HOME)
|
||||
}
|
||||
ATTR_AUTO = "auto"
|
||||
|
||||
# List of ON/OFF state tuples for groupable states
|
||||
_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME),
|
||||
(STATE_OPEN, STATE_CLOSED)]
|
||||
|
||||
|
||||
def _get_group_type(state):
|
||||
""" Determine the group type based on the given group type. """
|
||||
for group_type, states in _GROUP_TYPES.items():
|
||||
def _get_group_on_off(state):
|
||||
""" Determine the group on/off states based on a state. """
|
||||
for states in _GROUP_TYPES:
|
||||
if state in states:
|
||||
return group_type
|
||||
return states
|
||||
|
||||
return None
|
||||
return None, None
|
||||
|
||||
|
||||
def is_on(statemachine, entity_id):
|
||||
def is_on(hass, entity_id):
|
||||
""" Returns if the group state is in its ON-state. """
|
||||
state = statemachine.get_state(entity_id)
|
||||
state = hass.states.get(entity_id)
|
||||
|
||||
if state:
|
||||
group_type = _get_group_type(state.state)
|
||||
group_on, _ = _get_group_on_off(state.state)
|
||||
|
||||
if group_type:
|
||||
# We found group_type, compare to ON-state
|
||||
return state.state == _GROUP_TYPES[group_type][0]
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
# If we found a group_type, compare to ON-state
|
||||
return group_on is not None and state.state == group_on
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def expand_entity_ids(statemachine, entity_ids):
|
||||
def expand_entity_ids(hass, entity_ids):
|
||||
""" Returns the given list of entity ids and expands group ids into
|
||||
the entity ids it represents if found. """
|
||||
found_ids = []
|
||||
|
||||
for entity_id in entity_ids:
|
||||
if not isinstance(entity_id, str):
|
||||
continue
|
||||
|
||||
entity_id = entity_id.lower()
|
||||
|
||||
try:
|
||||
# If entity_id points at a group, expand it
|
||||
domain, _ = util.split_entity_id(entity_id)
|
||||
@@ -61,7 +67,7 @@ def expand_entity_ids(statemachine, entity_ids):
|
||||
if domain == DOMAIN:
|
||||
found_ids.extend(
|
||||
ent_id for ent_id
|
||||
in get_entity_ids(statemachine, entity_id)
|
||||
in get_entity_ids(hass, entity_id)
|
||||
if ent_id not in found_ids)
|
||||
|
||||
else:
|
||||
@@ -75,97 +81,149 @@ def expand_entity_ids(statemachine, entity_ids):
|
||||
return found_ids
|
||||
|
||||
|
||||
def get_entity_ids(statemachine, entity_id):
|
||||
def get_entity_ids(hass, entity_id, domain_filter=None):
|
||||
""" Get the entity ids that make up this group. """
|
||||
entity_id = entity_id.lower()
|
||||
|
||||
try:
|
||||
return \
|
||||
statemachine.get_state(entity_id).attributes[ATTR_ENTITY_ID]
|
||||
entity_ids = hass.states.get(entity_id).attributes[ATTR_ENTITY_ID]
|
||||
|
||||
if domain_filter:
|
||||
domain_filter = domain_filter.lower()
|
||||
|
||||
return [ent_id for ent_id in entity_ids
|
||||
if ent_id.startswith(domain_filter)]
|
||||
else:
|
||||
return entity_ids
|
||||
|
||||
except (AttributeError, KeyError):
|
||||
# AttributeError if state did not exist
|
||||
# KeyError if key did not exist in attributes
|
||||
return []
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-locals
|
||||
def setup(bus, statemachine, name, entity_ids):
|
||||
def setup(hass, config):
|
||||
""" Sets up all groups found definded in the configuration. """
|
||||
for name, entity_ids in config.get(DOMAIN, {}).items():
|
||||
if isinstance(entity_ids, str):
|
||||
entity_ids = [ent.strip() for ent in entity_ids.split(",")]
|
||||
setup_group(hass, name, entity_ids)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Group(Entity):
|
||||
""" Tracks a group of entity ids. """
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def __init__(self, hass, name, entity_ids=None, user_defined=True):
|
||||
self.hass = hass
|
||||
self._name = name
|
||||
self._state = STATE_UNKNOWN
|
||||
self.user_defined = user_defined
|
||||
self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass)
|
||||
self.tracking = []
|
||||
self.group_on = None
|
||||
self.group_off = None
|
||||
|
||||
if entity_ids is not None:
|
||||
self.update_tracked_entity_ids(entity_ids)
|
||||
else:
|
||||
self.update_ha_state(True)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
return {
|
||||
ATTR_ENTITY_ID: self.tracking,
|
||||
ATTR_AUTO: not self.user_defined,
|
||||
}
|
||||
|
||||
def update_tracked_entity_ids(self, entity_ids):
|
||||
""" Update the tracked entity IDs. """
|
||||
self.stop()
|
||||
self.tracking = tuple(ent_id.lower() for ent_id in entity_ids)
|
||||
self.group_on, self.group_off = None, None
|
||||
|
||||
self.update_ha_state(True)
|
||||
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
""" Starts the tracking. """
|
||||
track_state_change(
|
||||
self.hass, self.tracking, self._state_changed_listener)
|
||||
|
||||
def stop(self):
|
||||
""" Unregisters the group from Home Assistant. """
|
||||
self.hass.states.remove(self.entity_id)
|
||||
|
||||
self.hass.bus.remove_listener(
|
||||
ha.EVENT_STATE_CHANGED, self._state_changed_listener)
|
||||
|
||||
def update(self):
|
||||
""" Query all the tracked states and determine current group state. """
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
for entity_id in self.tracking:
|
||||
state = self.hass.states.get(entity_id)
|
||||
|
||||
if state is not None:
|
||||
self._process_tracked_state(state)
|
||||
|
||||
def _state_changed_listener(self, entity_id, old_state, new_state):
|
||||
""" Listener to receive state changes of tracked entities. """
|
||||
self._process_tracked_state(new_state)
|
||||
self.update_ha_state()
|
||||
|
||||
def _process_tracked_state(self, tr_state):
|
||||
""" Updates group state based on a new state of a tracked entity. """
|
||||
|
||||
# We have not determined type of group yet
|
||||
if self.group_on is None:
|
||||
self.group_on, self.group_off = _get_group_on_off(tr_state.state)
|
||||
|
||||
if self.group_on is not None:
|
||||
# New state of the group is going to be based on the first
|
||||
# state that we can recognize
|
||||
self._state = tr_state.state
|
||||
|
||||
return
|
||||
|
||||
# There is already a group state
|
||||
cur_gr_state = self._state
|
||||
group_on, group_off = self.group_on, self.group_off
|
||||
|
||||
# if cur_gr_state = OFF and tr_state = ON: set ON
|
||||
# if cur_gr_state = ON and tr_state = OFF: research
|
||||
# else: ignore
|
||||
|
||||
if cur_gr_state == group_off and tr_state.state == group_on:
|
||||
self._state = group_on
|
||||
|
||||
elif cur_gr_state == group_on and tr_state.state == group_off:
|
||||
|
||||
# Set to off if no other states are on
|
||||
if not any(self.hass.states.is_state(ent_id, group_on)
|
||||
for ent_id in self.tracking
|
||||
if tr_state.entity_id != ent_id):
|
||||
self._state = group_off
|
||||
|
||||
|
||||
def setup_group(hass, name, entity_ids, user_defined=True):
|
||||
""" Sets up a group state that is the combined state of
|
||||
several states. Supports ON/OFF and DEVICE_HOME/DEVICE_NOT_HOME. """
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Loop over the given entities to:
|
||||
# - determine which group type this is (on_off, device_home)
|
||||
# - if all states exist and have valid states
|
||||
# - retrieve the current state of the group
|
||||
errors = []
|
||||
group_type, group_on, group_off, group_state = None, None, None, None
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = statemachine.get_state(entity_id)
|
||||
|
||||
# Try to determine group type if we didn't yet
|
||||
if not group_type and state:
|
||||
group_type = _get_group_type(state.state)
|
||||
|
||||
if group_type:
|
||||
group_on, group_off = _GROUP_TYPES[group_type]
|
||||
group_state = group_off
|
||||
|
||||
else:
|
||||
# We did not find a matching group_type
|
||||
errors.append("Found unexpected state '{}'".format(
|
||||
name, state.state))
|
||||
|
||||
break
|
||||
|
||||
# Check if entity exists
|
||||
if not state:
|
||||
errors.append("Entity {} does not exist".format(entity_id))
|
||||
|
||||
# Check if entity is valid state
|
||||
elif state.state != group_off and state.state != group_on:
|
||||
|
||||
errors.append("State of {} is {} (expected: {}, {})".format(
|
||||
entity_id, state.state, group_off, group_on))
|
||||
|
||||
# Keep track of the group state to init later on
|
||||
elif group_state == group_off and state.state == group_on:
|
||||
group_state = group_on
|
||||
|
||||
if errors:
|
||||
logger.error("Error setting up state group {}: {}".format(
|
||||
name, ", ".join(errors)))
|
||||
|
||||
return False
|
||||
|
||||
group_entity_id = ENTITY_ID_FORMAT.format(name)
|
||||
state_attr = {ATTR_ENTITY_ID: entity_ids}
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def update_group_state(entity_id, old_state, new_state):
|
||||
""" Updates the group state based on a state change by a tracked
|
||||
entity. """
|
||||
|
||||
cur_group_state = statemachine.get_state(group_entity_id).state
|
||||
|
||||
# if cur_group_state = OFF and new_state = ON: set ON
|
||||
# if cur_group_state = ON and new_state = OFF: research
|
||||
# else: ignore
|
||||
|
||||
if cur_group_state == group_off and new_state.state == group_on:
|
||||
|
||||
statemachine.set_state(group_entity_id, group_on, state_attr)
|
||||
|
||||
elif cur_group_state == group_on and new_state.state == group_off:
|
||||
|
||||
# Check if any of the other states is still on
|
||||
if not any([statemachine.is_state(ent_id, group_on)
|
||||
for ent_id in entity_ids if entity_id != ent_id]):
|
||||
statemachine.set_state(group_entity_id, group_off, state_attr)
|
||||
|
||||
for entity_id in entity_ids:
|
||||
ha.track_state_change(bus, entity_id, update_group_state)
|
||||
|
||||
statemachine.set_state(group_entity_id, group_state, state_attr)
|
||||
|
||||
return True
|
||||
return Group(hass, name, entity_ids, user_defined)
|
||||
|
||||
153
homeassistant/components/history.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
homeassistant.components.history
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provide pre-made queries on top of the recorder component.
|
||||
"""
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from itertools import groupby
|
||||
from collections import defaultdict
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
import homeassistant.components.recorder as recorder
|
||||
from homeassistant.const import HTTP_BAD_REQUEST
|
||||
|
||||
DOMAIN = 'history'
|
||||
DEPENDENCIES = ['recorder', 'http']
|
||||
|
||||
URL_HISTORY_PERIOD = re.compile(
|
||||
r'/api/history/period(?:/(?P<date>\d{4}-\d{1,2}-\d{1,2})|)')
|
||||
|
||||
|
||||
def last_5_states(entity_id):
|
||||
""" Return the last 5 states for entity_id. """
|
||||
entity_id = entity_id.lower()
|
||||
|
||||
query = """
|
||||
SELECT * FROM states WHERE entity_id=? AND
|
||||
last_changed=last_updated
|
||||
ORDER BY state_id DESC LIMIT 0, 5
|
||||
"""
|
||||
|
||||
return recorder.query_states(query, (entity_id, ))
|
||||
|
||||
|
||||
def state_changes_during_period(start_time, end_time=None, entity_id=None):
|
||||
"""
|
||||
Return states changes during UTC period start_time - end_time.
|
||||
"""
|
||||
where = "last_changed=last_updated AND last_changed > ? "
|
||||
data = [start_time]
|
||||
|
||||
if end_time is not None:
|
||||
where += "AND last_changed < ? "
|
||||
data.append(end_time)
|
||||
|
||||
if entity_id is not None:
|
||||
where += "AND entity_id = ? "
|
||||
data.append(entity_id.lower())
|
||||
|
||||
query = ("SELECT * FROM states WHERE {} "
|
||||
"ORDER BY entity_id, last_changed ASC").format(where)
|
||||
|
||||
states = recorder.query_states(query, data)
|
||||
|
||||
result = defaultdict(list)
|
||||
|
||||
entity_ids = [entity_id] if entity_id is not None else None
|
||||
|
||||
# Get the states at the start time
|
||||
for state in get_states(start_time, entity_ids):
|
||||
state.last_changed = start_time
|
||||
result[state.entity_id].append(state)
|
||||
|
||||
# Append all changes to it
|
||||
for entity_id, group in groupby(states, lambda state: state.entity_id):
|
||||
result[entity_id].extend(group)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_states(utc_point_in_time, entity_ids=None, run=None):
|
||||
""" Returns the states at a specific point in time. """
|
||||
if run is None:
|
||||
run = recorder.run_information(utc_point_in_time)
|
||||
|
||||
# History did not run before utc_point_in_time
|
||||
if run is None:
|
||||
return []
|
||||
|
||||
where = run.where_after_start_run + "AND created < ? "
|
||||
where_data = [utc_point_in_time]
|
||||
|
||||
if entity_ids is not None:
|
||||
where += "AND entity_id IN ({}) ".format(
|
||||
",".join(['?'] * len(entity_ids)))
|
||||
where_data.extend(entity_ids)
|
||||
|
||||
query = """
|
||||
SELECT * FROM states
|
||||
INNER JOIN (
|
||||
SELECT max(state_id) AS max_state_id
|
||||
FROM states WHERE {}
|
||||
GROUP BY entity_id)
|
||||
WHERE state_id = max_state_id
|
||||
""".format(where)
|
||||
|
||||
return recorder.query_states(query, where_data)
|
||||
|
||||
|
||||
def get_state(utc_point_in_time, entity_id, run=None):
|
||||
""" Return a state at a specific point in time. """
|
||||
states = get_states(utc_point_in_time, (entity_id,), run)
|
||||
|
||||
return states[0] if states else None
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup(hass, config):
|
||||
""" Setup history hooks. """
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(
|
||||
r'/api/history/entity/(?P<entity_id>[a-zA-Z\._0-9]+)/'
|
||||
r'recent_states'),
|
||||
_api_last_5_states)
|
||||
|
||||
hass.http.register_path('GET', URL_HISTORY_PERIOD, _api_history_period)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=invalid-name
|
||||
def _api_last_5_states(handler, path_match, data):
|
||||
""" Return the last 5 states for an entity id as JSON. """
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
handler.write_json(last_5_states(entity_id))
|
||||
|
||||
|
||||
def _api_history_period(handler, path_match, data):
|
||||
""" Return history over a period of time. """
|
||||
date_str = path_match.group('date')
|
||||
one_day = timedelta(seconds=86400)
|
||||
|
||||
if date_str:
|
||||
start_date = dt_util.date_str_to_date(date_str)
|
||||
|
||||
if start_date is None:
|
||||
handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
start_time = dt_util.as_utc(dt_util.start_of_local_day(start_date))
|
||||
else:
|
||||
start_time = dt_util.utcnow() - one_day
|
||||
|
||||
end_time = start_time + one_day
|
||||
|
||||
entity_id = data.get('filter_entity_id')
|
||||
|
||||
handler.write_json(
|
||||
state_changes_during_period(start_time, end_time, entity_id).values())
|
||||
543
homeassistant/components/http.py
Normal file
@@ -0,0 +1,543 @@
|
||||
"""
|
||||
homeassistant.components.httpinterface
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module provides an API and a HTTP interface for debug purposes.
|
||||
|
||||
By default it will run on port 8123.
|
||||
|
||||
All API calls have to be accompanied by an 'api_password' parameter and will
|
||||
return JSON. If successful calls will return status code 200 or 201.
|
||||
|
||||
Other status codes that can occur are:
|
||||
- 400 (Bad Request)
|
||||
- 401 (Unauthorized)
|
||||
- 404 (Not Found)
|
||||
- 405 (Method not allowed)
|
||||
|
||||
The api supports the following actions:
|
||||
|
||||
/api - GET
|
||||
Returns message if API is up and running.
|
||||
Example result:
|
||||
{
|
||||
"message": "API running."
|
||||
}
|
||||
|
||||
/api/states - GET
|
||||
Returns a list of entities for which a state is available
|
||||
Example result:
|
||||
[
|
||||
{ .. state object .. },
|
||||
{ .. state object .. }
|
||||
]
|
||||
|
||||
/api/states/<entity_id> - GET
|
||||
Returns the current state from an entity
|
||||
Example result:
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
|
||||
/api/states/<entity_id> - POST
|
||||
Updates the current state of an entity. Returns status code 201 if successful
|
||||
with location header of updated resource and as body the new state.
|
||||
parameter: new_state - string
|
||||
optional parameter: attributes - JSON encoded object
|
||||
Example result:
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
|
||||
/api/events/<event_type> - POST
|
||||
Fires an event with event_type
|
||||
optional parameter: event_data - JSON encoded object
|
||||
Example result:
|
||||
{
|
||||
"message": "Event download_file fired."
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
import gzip
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from datetime import timedelta
|
||||
from homeassistant.util import Throttle
|
||||
from http.server import SimpleHTTPRequestHandler, HTTPServer
|
||||
from http import cookies
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.const import (
|
||||
SERVER_PORT, CONTENT_TYPE_JSON,
|
||||
HTTP_HEADER_HA_AUTH, HTTP_HEADER_CONTENT_TYPE, HTTP_HEADER_ACCEPT_ENCODING,
|
||||
HTTP_HEADER_CONTENT_ENCODING, HTTP_HEADER_VARY, HTTP_HEADER_CONTENT_LENGTH,
|
||||
HTTP_HEADER_CACHE_CONTROL, HTTP_HEADER_EXPIRES, HTTP_OK, HTTP_UNAUTHORIZED,
|
||||
HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED, HTTP_UNPROCESSABLE_ENTITY)
|
||||
import homeassistant.remote as rem
|
||||
import homeassistant.util as util
|
||||
import homeassistant.util.dt as date_util
|
||||
import homeassistant.bootstrap as bootstrap
|
||||
|
||||
DOMAIN = "http"
|
||||
DEPENDENCIES = []
|
||||
|
||||
CONF_API_PASSWORD = "api_password"
|
||||
CONF_SERVER_HOST = "server_host"
|
||||
CONF_SERVER_PORT = "server_port"
|
||||
CONF_DEVELOPMENT = "development"
|
||||
CONF_SESSIONS_ENABLED = "sessions_enabled"
|
||||
|
||||
DATA_API_PASSWORD = 'api_password'
|
||||
|
||||
# Throttling time in seconds for expired sessions check
|
||||
MIN_SEC_SESSION_CLEARING = timedelta(seconds=20)
|
||||
SESSION_TIMEOUT_SECONDS = 1800
|
||||
SESSION_KEY = 'sessionId'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config=None):
|
||||
""" Sets up the HTTP API and debug interface. """
|
||||
if config is None or DOMAIN not in config:
|
||||
config = {DOMAIN: {}}
|
||||
|
||||
api_password = util.convert(config[DOMAIN].get(CONF_API_PASSWORD), str)
|
||||
|
||||
no_password_set = api_password is None
|
||||
|
||||
if no_password_set:
|
||||
api_password = util.get_random_string()
|
||||
|
||||
# If no server host is given, accept all incoming requests
|
||||
server_host = config[DOMAIN].get(CONF_SERVER_HOST, '0.0.0.0')
|
||||
|
||||
server_port = config[DOMAIN].get(CONF_SERVER_PORT, SERVER_PORT)
|
||||
|
||||
development = str(config[DOMAIN].get(CONF_DEVELOPMENT, "")) == "1"
|
||||
|
||||
sessions_enabled = config[DOMAIN].get(CONF_SESSIONS_ENABLED, True)
|
||||
|
||||
try:
|
||||
server = HomeAssistantHTTPServer(
|
||||
(server_host, server_port), RequestHandler, hass, api_password,
|
||||
development, no_password_set, sessions_enabled)
|
||||
except OSError:
|
||||
# Happens if address already in use
|
||||
_LOGGER.exception("Error setting up HTTP server")
|
||||
return False
|
||||
|
||||
hass.bus.listen_once(
|
||||
ha.EVENT_HOMEASSISTANT_START,
|
||||
lambda event:
|
||||
threading.Thread(target=server.start, daemon=True).start())
|
||||
|
||||
hass.http = server
|
||||
hass.config.api = rem.API(util.get_local_ip(), api_password, server_port)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
""" Handle HTTP requests in a threaded fashion. """
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, server_address, request_handler_class,
|
||||
hass, api_password, development, no_password_set,
|
||||
sessions_enabled):
|
||||
super().__init__(server_address, request_handler_class)
|
||||
|
||||
self.server_address = server_address
|
||||
self.hass = hass
|
||||
self.api_password = api_password
|
||||
self.development = development
|
||||
self.no_password_set = no_password_set
|
||||
self.paths = []
|
||||
self.sessions = SessionStore(sessions_enabled)
|
||||
|
||||
# We will lazy init this one if needed
|
||||
self.event_forwarder = None
|
||||
|
||||
if development:
|
||||
_LOGGER.info("running http in development mode")
|
||||
|
||||
def start(self):
|
||||
""" Starts the HTTP server. """
|
||||
def stop_http(event):
|
||||
""" Stops the HTTP server. """
|
||||
self.shutdown()
|
||||
|
||||
self.hass.bus.listen_once(ha.EVENT_HOMEASSISTANT_STOP, stop_http)
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting web interface at http://%s:%d", *self.server_address)
|
||||
|
||||
# 31-1-2015: Refactored frontend/api components out of this component
|
||||
# To prevent stuff from breaking, load the two extracted components
|
||||
bootstrap.setup_component(self.hass, 'api')
|
||||
bootstrap.setup_component(self.hass, 'frontend')
|
||||
|
||||
self.serve_forever()
|
||||
|
||||
def register_path(self, method, url, callback, require_auth=True):
|
||||
""" Registers a path with the server. """
|
||||
self.paths.append((method, url, callback, require_auth))
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
""" Redirect built-in log to HA logging """
|
||||
# pylint: disable=no-self-use
|
||||
_LOGGER.info(fmt, *args)
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods,too-many-locals
|
||||
class RequestHandler(SimpleHTTPRequestHandler):
|
||||
"""
|
||||
Handles incoming HTTP requests
|
||||
|
||||
We extend from SimpleHTTPRequestHandler instead of Base so we
|
||||
can use the guess content type methods.
|
||||
"""
|
||||
|
||||
server_version = "HomeAssistant/1.0"
|
||||
|
||||
def __init__(self, req, client_addr, server):
|
||||
""" Contructor, call the base constructor and set up session """
|
||||
self._session = None
|
||||
SimpleHTTPRequestHandler.__init__(self, req, client_addr, server)
|
||||
|
||||
def log_message(self, fmt, *arguments):
|
||||
""" Redirect built-in log to HA logging """
|
||||
if self.server.no_password_set:
|
||||
_LOGGER.info(fmt, *arguments)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
fmt, *(arg.replace(self.server.api_password, '*******')
|
||||
if isinstance(arg, str) else arg for arg in arguments))
|
||||
|
||||
def _handle_request(self, method): # pylint: disable=too-many-branches
|
||||
""" Does some common checks and calls appropriate method. """
|
||||
url = urlparse(self.path)
|
||||
|
||||
# Read query input
|
||||
data = parse_qs(url.query)
|
||||
|
||||
# parse_qs gives a list for each value, take the latest element
|
||||
for key in data:
|
||||
data[key] = data[key][-1]
|
||||
|
||||
# Did we get post input ?
|
||||
content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0))
|
||||
|
||||
if content_length:
|
||||
body_content = self.rfile.read(content_length).decode("UTF-8")
|
||||
|
||||
try:
|
||||
data.update(json.loads(body_content))
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if JSON object is not a dict
|
||||
# ValueError if we could not parse JSON
|
||||
_LOGGER.exception(
|
||||
"Exception parsing JSON: %s", body_content)
|
||||
self.write_json_message(
|
||||
"Error parsing JSON", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
self._session = self.get_session()
|
||||
if self.server.no_password_set:
|
||||
api_password = self.server.api_password
|
||||
else:
|
||||
api_password = self.headers.get(HTTP_HEADER_HA_AUTH)
|
||||
|
||||
if not api_password and DATA_API_PASSWORD in data:
|
||||
api_password = data[DATA_API_PASSWORD]
|
||||
|
||||
if not api_password and self._session is not None:
|
||||
api_password = self._session.cookie_values.get(
|
||||
CONF_API_PASSWORD)
|
||||
|
||||
if '_METHOD' in data:
|
||||
method = data.pop('_METHOD')
|
||||
|
||||
# Var to keep track if we found a path that matched a handler but
|
||||
# the method was different
|
||||
path_matched_but_not_method = False
|
||||
|
||||
# Var to hold the handler for this path and method if found
|
||||
handle_request_method = False
|
||||
require_auth = True
|
||||
|
||||
# Check every handler to find matching result
|
||||
for t_method, t_path, t_handler, t_auth in self.server.paths:
|
||||
# we either do string-comparison or regular expression matching
|
||||
# pylint: disable=maybe-no-member
|
||||
if isinstance(t_path, str):
|
||||
path_match = url.path == t_path
|
||||
else:
|
||||
path_match = t_path.match(url.path)
|
||||
|
||||
if path_match and method == t_method:
|
||||
# Call the method
|
||||
handle_request_method = t_handler
|
||||
require_auth = t_auth
|
||||
break
|
||||
|
||||
elif path_match:
|
||||
path_matched_but_not_method = True
|
||||
|
||||
# Did we find a handler for the incoming request?
|
||||
if handle_request_method:
|
||||
|
||||
# For some calls we need a valid password
|
||||
if require_auth and api_password != self.server.api_password:
|
||||
self.write_json_message(
|
||||
"API password missing or incorrect.", HTTP_UNAUTHORIZED)
|
||||
|
||||
else:
|
||||
if self._session is None and require_auth:
|
||||
self._session = self.server.sessions.create(
|
||||
api_password)
|
||||
|
||||
handle_request_method(self, path_match, data)
|
||||
|
||||
elif path_matched_but_not_method:
|
||||
self.send_response(HTTP_METHOD_NOT_ALLOWED)
|
||||
self.end_headers()
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
self.end_headers()
|
||||
|
||||
def do_HEAD(self): # pylint: disable=invalid-name
|
||||
""" HEAD request handler. """
|
||||
self._handle_request('HEAD')
|
||||
|
||||
def do_GET(self): # pylint: disable=invalid-name
|
||||
""" GET request handler. """
|
||||
self._handle_request('GET')
|
||||
|
||||
def do_POST(self): # pylint: disable=invalid-name
|
||||
""" POST request handler. """
|
||||
self._handle_request('POST')
|
||||
|
||||
def do_PUT(self): # pylint: disable=invalid-name
|
||||
""" PUT request handler. """
|
||||
self._handle_request('PUT')
|
||||
|
||||
def do_DELETE(self): # pylint: disable=invalid-name
|
||||
""" DELETE request handler. """
|
||||
self._handle_request('DELETE')
|
||||
|
||||
def write_json_message(self, message, status_code=HTTP_OK):
|
||||
""" Helper method to return a message to the caller. """
|
||||
self.write_json({'message': message}, status_code=status_code)
|
||||
|
||||
def write_json(self, data=None, status_code=HTTP_OK, location=None):
|
||||
""" Helper method to return JSON to the caller. """
|
||||
self.send_response(status_code)
|
||||
self.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON)
|
||||
|
||||
if location:
|
||||
self.send_header('Location', location)
|
||||
|
||||
self.set_session_cookie_header()
|
||||
|
||||
self.end_headers()
|
||||
|
||||
if data is not None:
|
||||
self.wfile.write(
|
||||
json.dumps(data, indent=4, sort_keys=True,
|
||||
cls=rem.JSONEncoder).encode("UTF-8"))
|
||||
|
||||
def write_file(self, path):
|
||||
""" Returns a file to the user. """
|
||||
try:
|
||||
with open(path, 'rb') as inp:
|
||||
self.write_file_pointer(self.guess_type(path), inp)
|
||||
|
||||
except IOError:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
self.end_headers()
|
||||
_LOGGER.exception("Unable to serve %s", path)
|
||||
|
||||
def write_file_pointer(self, content_type, inp):
|
||||
"""
|
||||
Helper function to write a file pointer to the user.
|
||||
Does not do error handling.
|
||||
"""
|
||||
do_gzip = 'gzip' in self.headers.get(HTTP_HEADER_ACCEPT_ENCODING, '')
|
||||
|
||||
self.send_response(HTTP_OK)
|
||||
self.send_header(HTTP_HEADER_CONTENT_TYPE, content_type)
|
||||
|
||||
self.set_cache_header()
|
||||
self.set_session_cookie_header()
|
||||
|
||||
if do_gzip:
|
||||
gzip_data = gzip.compress(inp.read())
|
||||
|
||||
self.send_header(HTTP_HEADER_CONTENT_ENCODING, "gzip")
|
||||
self.send_header(HTTP_HEADER_VARY, HTTP_HEADER_ACCEPT_ENCODING)
|
||||
self.send_header(HTTP_HEADER_CONTENT_LENGTH, str(len(gzip_data)))
|
||||
|
||||
else:
|
||||
fst = os.fstat(inp.fileno())
|
||||
self.send_header(HTTP_HEADER_CONTENT_LENGTH, str(fst[6]))
|
||||
|
||||
self.end_headers()
|
||||
|
||||
if self.command == 'HEAD':
|
||||
return
|
||||
|
||||
elif do_gzip:
|
||||
self.wfile.write(gzip_data)
|
||||
|
||||
else:
|
||||
self.copyfile(inp, self.wfile)
|
||||
|
||||
def set_cache_header(self):
|
||||
""" Add cache headers if not in development """
|
||||
if not self.server.development:
|
||||
# 1 year in seconds
|
||||
cache_time = 365 * 86400
|
||||
|
||||
self.send_header(
|
||||
HTTP_HEADER_CACHE_CONTROL,
|
||||
"public, max-age={}".format(cache_time))
|
||||
self.send_header(
|
||||
HTTP_HEADER_EXPIRES,
|
||||
self.date_time_string(time.time()+cache_time))
|
||||
|
||||
def set_session_cookie_header(self):
|
||||
""" Add the header for the session cookie """
|
||||
if self.server.sessions.enabled and self._session is not None:
|
||||
existing_sess_id = self.get_current_session_id()
|
||||
|
||||
if existing_sess_id != self._session.session_id:
|
||||
self.send_header(
|
||||
'Set-Cookie',
|
||||
SESSION_KEY+'='+self._session.session_id)
|
||||
|
||||
def get_session(self):
|
||||
""" Get the requested session object from cookie value """
|
||||
if self.server.sessions.enabled is not True:
|
||||
return None
|
||||
|
||||
session_id = self.get_current_session_id()
|
||||
if session_id is not None:
|
||||
session = self.server.sessions.get(session_id)
|
||||
if session is not None:
|
||||
session.reset_expiry()
|
||||
return session
|
||||
|
||||
return None
|
||||
|
||||
def get_current_session_id(self):
|
||||
"""
|
||||
Extracts the current session id from the
|
||||
cookie or returns None if not set
|
||||
"""
|
||||
cookie = cookies.SimpleCookie()
|
||||
|
||||
if self.headers.get('Cookie', None) is not None:
|
||||
cookie.load(self.headers.get("Cookie"))
|
||||
|
||||
if cookie.get(SESSION_KEY, False):
|
||||
return cookie[SESSION_KEY].value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ServerSession:
|
||||
""" A very simple session class """
|
||||
def __init__(self, session_id):
|
||||
""" Set up the expiry time on creation """
|
||||
self._expiry = 0
|
||||
self.reset_expiry()
|
||||
self.cookie_values = {}
|
||||
self.session_id = session_id
|
||||
|
||||
def reset_expiry(self):
|
||||
""" Resets the expiry based on current time """
|
||||
self._expiry = date_util.utcnow() + timedelta(
|
||||
seconds=SESSION_TIMEOUT_SECONDS)
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
""" Return true if the session is expired based on the expiry time """
|
||||
return self._expiry < date_util.utcnow()
|
||||
|
||||
|
||||
class SessionStore(object):
|
||||
""" Responsible for storing and retrieving http sessions """
|
||||
def __init__(self, enabled=True):
|
||||
""" Set up the session store """
|
||||
self._sessions = {}
|
||||
self.enabled = enabled
|
||||
self.session_lock = threading.RLock()
|
||||
|
||||
@Throttle(MIN_SEC_SESSION_CLEARING)
|
||||
def remove_expired(self):
|
||||
""" Remove any expired sessions. """
|
||||
if self.session_lock.acquire(False):
|
||||
try:
|
||||
keys = []
|
||||
for key in self._sessions.keys():
|
||||
keys.append(key)
|
||||
|
||||
for key in keys:
|
||||
if self._sessions[key].is_expired:
|
||||
del self._sessions[key]
|
||||
_LOGGER.info("Cleared expired session %s", key)
|
||||
finally:
|
||||
self.session_lock.release()
|
||||
|
||||
def add(self, key, session):
|
||||
""" Add a new session to the list of tracked sessions """
|
||||
self.remove_expired()
|
||||
with self.session_lock:
|
||||
self._sessions[key] = session
|
||||
|
||||
def get(self, key):
|
||||
""" get a session by key """
|
||||
self.remove_expired()
|
||||
session = self._sessions.get(key, None)
|
||||
if session is not None and session.is_expired:
|
||||
return None
|
||||
return session
|
||||
|
||||
def create(self, api_password):
|
||||
""" Creates a new session and adds it to the sessions """
|
||||
if self.enabled is not True:
|
||||
return None
|
||||
|
||||
chars = string.ascii_letters + string.digits
|
||||
session_id = ''.join([random.choice(chars) for i in range(20)])
|
||||
session = ServerSession(session_id)
|
||||
session.cookie_values[CONF_API_PASSWORD] = api_password
|
||||
self.add(session_id, session)
|
||||
return session
|
||||
@@ -1,689 +0,0 @@
|
||||
"""
|
||||
homeassistant.components.httpinterface
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module provides an API and a HTTP interface for debug purposes.
|
||||
|
||||
By default it will run on port 8123.
|
||||
|
||||
All API calls have to be accompanied by an 'api_password' parameter and will
|
||||
return JSON. If successful calls will return status code 200 or 201.
|
||||
|
||||
Other status codes that can occur are:
|
||||
- 400 (Bad Request)
|
||||
- 401 (Unauthorized)
|
||||
- 404 (Not Found)
|
||||
- 405 (Method not allowed)
|
||||
|
||||
The api supports the following actions:
|
||||
|
||||
/api/states - GET
|
||||
Returns a list of entities for which a state is available
|
||||
Example result:
|
||||
{
|
||||
"entity_ids": [
|
||||
"Paulus_Nexus_4",
|
||||
"weather.sun",
|
||||
"all_devices"
|
||||
]
|
||||
}
|
||||
|
||||
/api/states/<entity_id> - GET
|
||||
Returns the current state from an entity
|
||||
Example result:
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
|
||||
/api/states/<entity_id> - POST
|
||||
Updates the current state of an entity. Returns status code 201 if successful
|
||||
with location header of updated resource and as body the new state.
|
||||
parameter: new_state - string
|
||||
optional parameter: attributes - JSON encoded object
|
||||
Example result:
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
|
||||
/api/events/<event_type> - POST
|
||||
Fires an event with event_type
|
||||
optional parameter: event_data - JSON encoded object
|
||||
Example result:
|
||||
{
|
||||
"message": "Event download_file fired."
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
from urlparse import urlparse, parse_qs
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.util as util
|
||||
|
||||
SERVER_PORT = 8123
|
||||
|
||||
HTTP_OK = 200
|
||||
HTTP_CREATED = 201
|
||||
HTTP_MOVED_PERMANENTLY = 301
|
||||
HTTP_BAD_REQUEST = 400
|
||||
HTTP_UNAUTHORIZED = 401
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_METHOD_NOT_ALLOWED = 405
|
||||
HTTP_UNPROCESSABLE_ENTITY = 422
|
||||
|
||||
URL_ROOT = "/"
|
||||
URL_CHANGE_STATE = "/change_state"
|
||||
URL_FIRE_EVENT = "/fire_event"
|
||||
|
||||
URL_API_STATES = "/api/states"
|
||||
URL_API_STATES_ENTITY = "/api/states/{}"
|
||||
URL_API_EVENTS = "/api/events"
|
||||
URL_API_EVENTS_EVENT = "/api/events/{}"
|
||||
URL_API_SERVICES = "/api/services"
|
||||
URL_API_SERVICES_SERVICE = "/api/services/{}/{}"
|
||||
|
||||
URL_STATIC = "/static/{}"
|
||||
|
||||
|
||||
class HTTPInterface(threading.Thread):
|
||||
""" Provides an HTTP interface for Home Assistant. """
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, bus, statemachine, api_password,
|
||||
server_port=None, server_host=None):
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
self.daemon = True
|
||||
|
||||
if not server_port:
|
||||
server_port = SERVER_PORT
|
||||
|
||||
# If no server host is given, accept all incoming requests
|
||||
if not server_host:
|
||||
server_host = '0.0.0.0'
|
||||
|
||||
self.server = HTTPServer((server_host, server_port), RequestHandler)
|
||||
|
||||
self.server.flash_message = None
|
||||
self.server.logger = logging.getLogger(__name__)
|
||||
self.server.bus = bus
|
||||
self.server.statemachine = statemachine
|
||||
self.server.api_password = api_password
|
||||
|
||||
bus.listen_once_event(ha.EVENT_HOMEASSISTANT_START,
|
||||
lambda event: self.start())
|
||||
|
||||
def run(self):
|
||||
""" Start the HTTP interface. """
|
||||
self.server.logger.info("Starting")
|
||||
|
||||
self.server.serve_forever()
|
||||
|
||||
|
||||
class RequestHandler(BaseHTTPRequestHandler):
|
||||
""" Handles incoming HTTP requests """
|
||||
|
||||
PATHS = [ # debug interface
|
||||
('GET', '/', '_handle_get_root'),
|
||||
('POST', re.compile(r'/change_state'), '_handle_change_state'),
|
||||
('POST', re.compile(r'/fire_event'), '_handle_fire_event'),
|
||||
('POST', re.compile(r'/call_service'), '_handle_call_service'),
|
||||
|
||||
# /states
|
||||
('GET', '/api/states', '_handle_get_api_states'),
|
||||
('GET',
|
||||
re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
'_handle_get_api_states_entity'),
|
||||
('POST',
|
||||
re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
'_handle_change_state'),
|
||||
|
||||
# /events
|
||||
('GET', '/api/events', '_handle_get_api_events'),
|
||||
('POST',
|
||||
re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'),
|
||||
'_handle_fire_event'),
|
||||
|
||||
# /services
|
||||
('GET', '/api/services', '_handle_get_api_services'),
|
||||
('POST',
|
||||
re.compile((r'/api/services/'
|
||||
r'(?P<domain>[a-zA-Z\._0-9]+)/'
|
||||
r'(?P<service>[a-zA-Z\._0-9]+)')),
|
||||
'_handle_call_service'),
|
||||
|
||||
# Statis files
|
||||
('GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
'_handle_get_static')
|
||||
]
|
||||
|
||||
use_json = False
|
||||
|
||||
def _handle_request(self, method): # pylint: disable=too-many-branches
|
||||
""" Does some common checks and calls appropriate method. """
|
||||
url = urlparse(self.path)
|
||||
|
||||
# Read query input
|
||||
data = parse_qs(url.query)
|
||||
|
||||
# Did we get post input ?
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
|
||||
if content_length:
|
||||
data.update(parse_qs(self.rfile.read(content_length)))
|
||||
|
||||
try:
|
||||
api_password = data['api_password'][0]
|
||||
except KeyError:
|
||||
api_password = ''
|
||||
|
||||
if url.path.startswith('/api/'):
|
||||
self.use_json = True
|
||||
|
||||
# Var to keep track if we found a path that matched a handler but
|
||||
# the method was different
|
||||
path_matched_but_not_method = False
|
||||
|
||||
# Var to hold the handler for this path and method if found
|
||||
handle_request_method = False
|
||||
|
||||
# Check every handler to find matching result
|
||||
for t_method, t_path, t_handler in RequestHandler.PATHS:
|
||||
|
||||
# we either do string-comparison or regular expression matching
|
||||
# pylint: disable=maybe-no-member
|
||||
if isinstance(t_path, str):
|
||||
path_match = url.path == t_path
|
||||
else:
|
||||
path_match = t_path.match(url.path)
|
||||
|
||||
if path_match and method == t_method:
|
||||
# Call the method
|
||||
handle_request_method = getattr(self, t_handler)
|
||||
break
|
||||
|
||||
elif path_match:
|
||||
path_matched_but_not_method = True
|
||||
|
||||
# Did we find a handler for the incoming request?
|
||||
if handle_request_method:
|
||||
|
||||
# Do not enforce api password for static files
|
||||
if handle_request_method == self._handle_get_static or \
|
||||
self._verify_api_password(api_password):
|
||||
|
||||
handle_request_method(path_match, data)
|
||||
|
||||
elif path_matched_but_not_method:
|
||||
self.send_response(HTTP_METHOD_NOT_ALLOWED)
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
|
||||
def do_GET(self): # pylint: disable=invalid-name
|
||||
""" GET request handler. """
|
||||
self._handle_request('GET')
|
||||
|
||||
def do_POST(self): # pylint: disable=invalid-name
|
||||
""" POST request handler. """
|
||||
self._handle_request('POST')
|
||||
|
||||
def _verify_api_password(self, api_password):
|
||||
""" Helper method to verify the API password
|
||||
and take action if incorrect. """
|
||||
if api_password == self.server.api_password:
|
||||
return True
|
||||
|
||||
elif self.use_json:
|
||||
self._message(
|
||||
"API password missing or incorrect.", HTTP_UNAUTHORIZED)
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_OK)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
self.end_headers()
|
||||
|
||||
self.wfile.write((
|
||||
"<html>"
|
||||
"<head><title>Home Assistant</title>"
|
||||
"<link rel='stylesheet' type='text/css' "
|
||||
" href='/static/style.css'>"
|
||||
"<link rel='icon' href='/static/favicon.ico' "
|
||||
" type='image/x-icon' />"
|
||||
"</head>"
|
||||
"<body>"
|
||||
"<div class='container'>"
|
||||
"<form class='form-signin' action='{}' method='GET'>"
|
||||
|
||||
"<input type='text' class='form-control' name='api_password' "
|
||||
" placeholder='API Password for Home Assistant' "
|
||||
" required autofocus>"
|
||||
|
||||
"<button class='btn btn-lg btn-primary btn-block' "
|
||||
" type='submit'>Enter</button>"
|
||||
|
||||
"</form>"
|
||||
"</div>"
|
||||
"</body></html>").format(self.path))
|
||||
|
||||
return False
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _handle_get_root(self, path_match, data):
|
||||
""" Renders the debug interface. """
|
||||
|
||||
write = lambda txt: self.wfile.write(txt.encode("UTF-8")+"\n")
|
||||
|
||||
self.send_response(HTTP_OK)
|
||||
self.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
self.end_headers()
|
||||
|
||||
write(("<html>"
|
||||
"<head><title>Home Assistant</title>"
|
||||
"<link rel='stylesheet' type='text/css' "
|
||||
" href='/static/style.css'>"
|
||||
"<link rel='icon' href='/static/favicon.ico' "
|
||||
" type='image/x-icon' />"
|
||||
"</head>"
|
||||
"<body>"
|
||||
"<div class='container'>"
|
||||
"<div class='page-header'><h1>Home Assistant</h1></div>"))
|
||||
|
||||
# Flash message support
|
||||
if self.server.flash_message:
|
||||
write(("<div class='row'><div class='col-xs-12'>"
|
||||
"<div class='alert alert-success'>"
|
||||
"{}</div></div></div>").format(self.server.flash_message))
|
||||
|
||||
self.server.flash_message = None
|
||||
|
||||
# Describe state machine:
|
||||
write(("<div class='row'>"
|
||||
"<div class='col-xs-12'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" States</h2></div>"
|
||||
"<form method='post' action='/change_state' "
|
||||
" class='form-change-state'>"
|
||||
"<input type='hidden' name='api_password' value='{}'>"
|
||||
"<table class='table'><tr>"
|
||||
"<th>Entity ID</th><th>State</th>"
|
||||
"<th>Attributes</th><th>Last Changed</th>"
|
||||
"</tr>").format(self.server.api_password))
|
||||
|
||||
for entity_id in \
|
||||
sorted(self.server.statemachine.entity_ids,
|
||||
key=lambda key: key.lower()):
|
||||
|
||||
state = self.server.statemachine.get_state(entity_id)
|
||||
|
||||
attributes = u"<br>".join(
|
||||
[u"{}: {}".format(attr, state.attributes[attr])
|
||||
for attr in state.attributes])
|
||||
|
||||
write((u"<tr>"
|
||||
u"<td>{}</td><td>{}</td><td>{}</td><td>{}</td>"
|
||||
u"</tr>").format(
|
||||
entity_id,
|
||||
state.state,
|
||||
attributes,
|
||||
util.datetime_to_str(state.last_changed)))
|
||||
|
||||
# Change state form
|
||||
write(("<tr><td><input name='entity_id' class='form-control' "
|
||||
" placeholder='Entity ID'></td>"
|
||||
"<td><input name='new_state' class='form-control' "
|
||||
" placeholder='New State'></td>"
|
||||
"<td><textarea rows='3' name='attributes' class='form-control' "
|
||||
" placeholder='State Attributes (JSON, optional)'>"
|
||||
"</textarea></td>"
|
||||
"<td><button type='submit' class='btn btn-default'>"
|
||||
"Set State</button></td></tr>"
|
||||
|
||||
"</table></form></div>"
|
||||
|
||||
"</div></div>"))
|
||||
|
||||
# Describe bus/services:
|
||||
write(("<div class='row'>"
|
||||
"<div class='col-xs-6'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" Services</h2></div>"
|
||||
"<table class='table'>"
|
||||
"<tr><th>Domain</th><th>Service</th></tr>"))
|
||||
|
||||
for domain, services in sorted(
|
||||
self.server.bus.services.items()):
|
||||
write("<tr><td>{}</td><td>{}</td></tr>".format(
|
||||
domain, ", ".join(services)))
|
||||
|
||||
write(("</table></div></div>"
|
||||
|
||||
"<div class='col-xs-6'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" Call Service</h2></div>"
|
||||
"<div class='panel-body'>"
|
||||
"<form method='post' action='/call_service' "
|
||||
" class='form-horizontal form-fire-event'>"
|
||||
"<input type='hidden' name='api_password' value='{}'>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='domain' class='col-xs-3 control-label'>"
|
||||
" Domain</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <input type='text' class='form-control' id='domain'"
|
||||
" name='domain' placeholder='Service Domain'>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='service' class='col-xs-3 control-label'>"
|
||||
" Service</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <input type='text' class='form-control' id='service'"
|
||||
" name='service' placeholder='Service name'>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='service_data' class='col-xs-3 control-label'>"
|
||||
" Service data</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <textarea rows='3' class='form-control' id='service_data'"
|
||||
" name='service_data' placeholder='Service Data "
|
||||
"(JSON, optional)'></textarea>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <div class='col-xs-offset-3 col-xs-9'>"
|
||||
" <button type='submit' class='btn btn-default'>"
|
||||
" Call Service</button>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
"</form>"
|
||||
"</div></div></div>"
|
||||
"</div>").format(self.server.api_password))
|
||||
|
||||
# Describe bus/events:
|
||||
write(("<div class='row'>"
|
||||
"<div class='col-xs-6'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" Events</h2></div>"
|
||||
"<table class='table'>"
|
||||
"<tr><th>Event</th><th>Listeners</th></tr>"))
|
||||
|
||||
for event, listener_count in sorted(
|
||||
self.server.bus.event_listeners.items()):
|
||||
write("<tr><td>{}</td><td>{}</td></tr>".format(
|
||||
event, listener_count))
|
||||
|
||||
write(("</table></div></div>"
|
||||
|
||||
"<div class='col-xs-6'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" Fire Event</h2></div>"
|
||||
"<div class='panel-body'>"
|
||||
"<form method='post' action='/fire_event' "
|
||||
" class='form-horizontal form-fire-event'>"
|
||||
"<input type='hidden' name='api_password' value='{}'>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='event_type' class='col-xs-3 control-label'>"
|
||||
" Event type</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <input type='text' class='form-control' id='event_type'"
|
||||
" name='event_type' placeholder='Event Type'>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='event_data' class='col-xs-3 control-label'>"
|
||||
" Event data</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <textarea rows='3' class='form-control' id='event_data'"
|
||||
" name='event_data' placeholder='Event Data "
|
||||
"(JSON, optional)'></textarea>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <div class='col-xs-offset-3 col-xs-9'>"
|
||||
" <button type='submit' class='btn btn-default'>"
|
||||
" Fire Event</button>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
"</form>"
|
||||
"</div></div></div>"
|
||||
"</div>").format(self.server.api_password))
|
||||
|
||||
write("</div></body></html>")
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_change_state(self, path_match, data):
|
||||
""" Handles updating the state of an entity.
|
||||
|
||||
This handles the following paths:
|
||||
/change_state
|
||||
/api/states/<entity_id>
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
entity_id = path_match.group('entity_id')
|
||||
except IndexError:
|
||||
# If group 'entity_id' does not exist in path_match
|
||||
entity_id = data['entity_id'][0]
|
||||
|
||||
new_state = data['new_state'][0]
|
||||
|
||||
try:
|
||||
attributes = json.loads(data['attributes'][0])
|
||||
except KeyError:
|
||||
# Happens if key 'attributes' does not exist
|
||||
attributes = None
|
||||
|
||||
# Write state
|
||||
self.server.statemachine.set_state(entity_id,
|
||||
new_state,
|
||||
attributes)
|
||||
|
||||
# Return state if json, else redirect to main page
|
||||
if self.use_json:
|
||||
state = self.server.statemachine.get_state(entity_id)
|
||||
|
||||
self._write_json(state.as_dict(),
|
||||
status_code=HTTP_CREATED,
|
||||
location=
|
||||
URL_API_STATES_ENTITY.format(entity_id))
|
||||
else:
|
||||
self._message(
|
||||
"State of {} changed to {}".format(entity_id, new_state))
|
||||
|
||||
except KeyError:
|
||||
# If new_state don't exist in post data
|
||||
self._message(
|
||||
"No new_state submitted.", HTTP_BAD_REQUEST)
|
||||
|
||||
except ValueError:
|
||||
# Occurs during error parsing json
|
||||
self._message(
|
||||
"Invalid JSON for attributes", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_fire_event(self, path_match, data):
|
||||
""" Handles firing of an event.
|
||||
|
||||
This handles the following paths:
|
||||
/fire_event
|
||||
/api/events/<event_type>
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
event_type = path_match.group('event_type')
|
||||
except IndexError:
|
||||
# If group event_type does not exist in path_match
|
||||
event_type = data['event_type'][0]
|
||||
|
||||
try:
|
||||
event_data = json.loads(data['event_data'][0])
|
||||
except KeyError:
|
||||
# Happens if key 'event_data' does not exist
|
||||
event_data = None
|
||||
|
||||
self.server.bus.fire_event(event_type, event_data)
|
||||
|
||||
self._message("Event {} fired.".format(event_type))
|
||||
|
||||
except KeyError:
|
||||
# Occurs if event_type does not exist in data
|
||||
self._message("No event_type received.", HTTP_BAD_REQUEST)
|
||||
|
||||
except ValueError:
|
||||
# Occurs during error parsing json
|
||||
self._message(
|
||||
"Invalid JSON for event_data", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
def _handle_call_service(self, path_match, data):
|
||||
""" Handles calling a service.
|
||||
|
||||
This handles the following paths:
|
||||
/call_service
|
||||
/api/services/<domain>/<service>
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
domain = path_match.group('domain')
|
||||
service = path_match.group('service')
|
||||
except IndexError:
|
||||
# If group domain or service does not exist in path_match
|
||||
domain = data['domain'][0]
|
||||
service = data['service'][0]
|
||||
|
||||
try:
|
||||
service_data = json.loads(data['service_data'][0])
|
||||
except KeyError:
|
||||
# Happens if key 'service_data' does not exist
|
||||
service_data = None
|
||||
|
||||
self.server.bus.call_service(domain, service, service_data)
|
||||
|
||||
self._message("Service {}/{} called.".format(domain, service))
|
||||
|
||||
except ha.ServiceDoesNotExistError:
|
||||
# If the service does not exist
|
||||
self._message('Service does not exist', HTTP_BAD_REQUEST)
|
||||
|
||||
except KeyError:
|
||||
# Occurs if domain or service does not exist in data
|
||||
self._message("No domain or service received.", HTTP_BAD_REQUEST)
|
||||
|
||||
except ValueError:
|
||||
# Occurs during error parsing json
|
||||
self._message(
|
||||
"Invalid JSON for service_data", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _handle_get_api_states(self, path_match, data):
|
||||
""" Returns the entitie ids which state are being tracked. """
|
||||
self._write_json({'entity_ids': self.server.statemachine.entity_ids})
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _handle_get_api_states_entity(self, path_match, data):
|
||||
""" Returns the state of a specific entity. """
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
state = self.server.statemachine.get_state(entity_id)
|
||||
|
||||
try:
|
||||
self._write_json(state.as_dict())
|
||||
except AttributeError:
|
||||
# If state for entity_id does not exist
|
||||
self._message("State does not exist.", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
def _handle_get_api_events(self, path_match, data):
|
||||
""" Handles getting overview of event listeners. """
|
||||
self._write_json({'event_listeners': self.server.bus.event_listeners})
|
||||
|
||||
def _handle_get_api_services(self, path_match, data):
|
||||
""" Handles getting overview of services. """
|
||||
self._write_json({'services': self.server.bus.services})
|
||||
|
||||
def _handle_get_static(self, path_match, data):
|
||||
""" Returns a static file. """
|
||||
req_file = util.sanitize_filename(path_match.group('file'))
|
||||
|
||||
path = os.path.join(os.path.dirname(__file__), 'www_static', req_file)
|
||||
|
||||
if os.path.isfile(path):
|
||||
self.send_response(HTTP_OK)
|
||||
|
||||
# TODO: correct header for mime-type and caching
|
||||
|
||||
self.end_headers()
|
||||
|
||||
with open(path, 'rb') as inp:
|
||||
data = inp.read(1024)
|
||||
|
||||
while data:
|
||||
self.wfile.write(data)
|
||||
|
||||
data = inp.read(1024)
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
self.end_headers()
|
||||
|
||||
def _message(self, message, status_code=HTTP_OK):
|
||||
""" Helper method to return a message to the caller. """
|
||||
if self.use_json:
|
||||
self._write_json({'message': message}, status_code=status_code)
|
||||
elif status_code == HTTP_OK:
|
||||
self.server.flash_message = message
|
||||
self._redirect('/')
|
||||
else:
|
||||
self.send_error(status_code, message)
|
||||
|
||||
def _redirect(self, location):
|
||||
""" Helper method to redirect caller. """
|
||||
self.send_response(HTTP_MOVED_PERMANENTLY)
|
||||
|
||||
self.send_header(
|
||||
"Location", "{}?api_password={}".format(
|
||||
location, self.server.api_password))
|
||||
|
||||
self.end_headers()
|
||||
|
||||
def _write_json(self, data=None, status_code=HTTP_OK, location=None):
|
||||
""" Helper method to return JSON to the caller. """
|
||||
self.send_response(status_code)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
|
||||
if location:
|
||||
self.send_header('Location', location)
|
||||
|
||||
self.end_headers()
|
||||
|
||||
if data:
|
||||
self.wfile.write(json.dumps(data, indent=4, sort_keys=True))
|
||||
|
Before Width: | Height: | Size: 318 B |
@@ -1,39 +0,0 @@
|
||||
@import url(//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.0.2/css/bootstrap.min.css);
|
||||
|
||||
.panel > form, .panel > form > .table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.panel .table {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.form-signin {
|
||||
max-width: 330px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-signin .form-control {
|
||||
margin-top: 40px;
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
height: auto;
|
||||
padding: 10px;
|
||||
margin-bottom: -1px;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-signin .btn-primary {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.form-fire-event {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.form-fire-event .form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
79
homeassistant/components/ifttt.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
homeassistant.components.ifttt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
This component enable you to trigger Maker IFTTT recipes.
|
||||
Check https://ifttt.com/maker for details.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use Maker IFTTT you will need to add something like the following to your
|
||||
config/configuration.yaml.
|
||||
|
||||
ifttt:
|
||||
key: xxxxx-x-xxxxxxxxxxxxx
|
||||
|
||||
Variables:
|
||||
|
||||
key
|
||||
*Required
|
||||
Your api key
|
||||
|
||||
"""
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "ifttt"
|
||||
|
||||
SERVICE_TRIGGER = 'trigger'
|
||||
|
||||
ATTR_EVENT = 'event'
|
||||
ATTR_VALUE1 = 'value1'
|
||||
ATTR_VALUE2 = 'value2'
|
||||
ATTR_VALUE3 = 'value3'
|
||||
|
||||
DEPENDENCIES = []
|
||||
|
||||
REQUIREMENTS = ['pyfttt==0.3']
|
||||
|
||||
|
||||
def trigger(hass, event, value1=None, value2=None, value3=None):
|
||||
""" Trigger a Maker IFTTT recipe """
|
||||
data = {
|
||||
ATTR_EVENT: event,
|
||||
ATTR_VALUE1: value1,
|
||||
ATTR_VALUE2: value2,
|
||||
ATTR_VALUE3: value3,
|
||||
}
|
||||
hass.services.call(DOMAIN, SERVICE_TRIGGER, data)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup the ifttt service component """
|
||||
|
||||
if not validate_config(config, {DOMAIN: ['key']}, _LOGGER):
|
||||
return False
|
||||
|
||||
key = config[DOMAIN]['key']
|
||||
|
||||
def trigger_service(call):
|
||||
""" Handle ifttt trigger service calls. """
|
||||
event = call.data.get(ATTR_EVENT)
|
||||
value1 = call.data.get(ATTR_VALUE1)
|
||||
value2 = call.data.get(ATTR_VALUE2)
|
||||
value3 = call.data.get(ATTR_VALUE3)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
try:
|
||||
import pyfttt as pyfttt
|
||||
pyfttt.send_event(key, event, value1, value2, value3)
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.exception("Error communicating with IFTTT")
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service)
|
||||
|
||||
return True
|
||||
44
homeassistant/components/introduction.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
homeassistant.components.introduction
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Component that will help guide the user taking its first steps.
|
||||
"""
|
||||
import logging
|
||||
|
||||
DOMAIN = 'introduction'
|
||||
DEPENDENCIES = []
|
||||
|
||||
|
||||
def setup(hass, config=None):
|
||||
""" Setup the introduction component. """
|
||||
log = logging.getLogger(__name__)
|
||||
log.info("""
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Hello, and welcome to Home Assistant!
|
||||
|
||||
We'll hope that we can make all your dreams come true.
|
||||
|
||||
Here are some resources to get started:
|
||||
|
||||
- Configuring Home Assistant:
|
||||
https://home-assistant.io/getting-started/configuration.html
|
||||
|
||||
- Available components:
|
||||
https://home-assistant.io/components/
|
||||
|
||||
- Troubleshooting your configuration:
|
||||
https://home-assistant.io/getting-started/troubleshooting-configuration.html
|
||||
|
||||
- Getting help:
|
||||
https://home-assistant.io/help/
|
||||
|
||||
This message is generated by the introduction component. You can
|
||||
disable it in configuration.yaml.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
""")
|
||||
|
||||
return True
|
||||
232
homeassistant/components/isy994.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
homeassistant.components.isy994
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Connects to an ISY-994 controller and loads relevant components to control its
|
||||
devices. Also contains the base classes for ISY Sensors, Lights, and Switches.
|
||||
|
||||
For configuration details please visit the documentation for this component at
|
||||
https://home-assistant.io/components/isy994.html
|
||||
"""
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant import bootstrap
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_USERNAME, CONF_PASSWORD, EVENT_PLATFORM_DISCOVERED,
|
||||
EVENT_HOMEASSISTANT_STOP, ATTR_SERVICE, ATTR_DISCOVERED,
|
||||
ATTR_FRIENDLY_NAME)
|
||||
|
||||
DOMAIN = "isy994"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['PyISY==1.0.5']
|
||||
DISCOVER_LIGHTS = "isy994.lights"
|
||||
DISCOVER_SWITCHES = "isy994.switches"
|
||||
DISCOVER_SENSORS = "isy994.sensors"
|
||||
ISY = None
|
||||
SENSOR_STRING = 'Sensor'
|
||||
HIDDEN_STRING = '{HIDE ME}'
|
||||
CONF_TLS_VER = 'tls'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""
|
||||
Setup ISY994 component.
|
||||
This will automatically import associated lights, switches, and sensors.
|
||||
"""
|
||||
try:
|
||||
import PyISY
|
||||
except ImportError:
|
||||
_LOGGER.error("Error while importing dependency PyISY.")
|
||||
return False
|
||||
|
||||
# pylint: disable=global-statement
|
||||
# check for required values in configuration file
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return False
|
||||
|
||||
# pull and parse standard configuration
|
||||
user = config[DOMAIN][CONF_USERNAME]
|
||||
password = config[DOMAIN][CONF_PASSWORD]
|
||||
host = urlparse(config[DOMAIN][CONF_HOST])
|
||||
addr = host.geturl()
|
||||
if host.scheme == 'http':
|
||||
addr = addr.replace('http://', '')
|
||||
https = False
|
||||
elif host.scheme == 'https':
|
||||
addr = addr.replace('https://', '')
|
||||
https = True
|
||||
else:
|
||||
_LOGGER.error('isy994 host value in configuration file is invalid.')
|
||||
return False
|
||||
port = host.port
|
||||
addr = addr.replace(':{}'.format(port), '')
|
||||
|
||||
# pull and parse optional configuration
|
||||
global SENSOR_STRING
|
||||
global HIDDEN_STRING
|
||||
SENSOR_STRING = str(config[DOMAIN].get('sensor_string', SENSOR_STRING))
|
||||
HIDDEN_STRING = str(config[DOMAIN].get('hidden_string', HIDDEN_STRING))
|
||||
tls_version = config[DOMAIN].get(CONF_TLS_VER, None)
|
||||
|
||||
# connect to ISY controller
|
||||
global ISY
|
||||
ISY = PyISY.ISY(addr, port, user, password, use_https=https,
|
||||
tls_ver=tls_version, log=_LOGGER)
|
||||
if not ISY.connected:
|
||||
return False
|
||||
|
||||
# listen for HA stop to disconnect
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
|
||||
|
||||
# Load components for the devices in the ISY controller that we support
|
||||
for comp_name, discovery in ((('sensor', DISCOVER_SENSORS),
|
||||
('light', DISCOVER_LIGHTS),
|
||||
('switch', DISCOVER_SWITCHES))):
|
||||
component = get_component(comp_name)
|
||||
bootstrap.setup_component(hass, component.DOMAIN, config)
|
||||
hass.bus.fire(EVENT_PLATFORM_DISCOVERED,
|
||||
{ATTR_SERVICE: discovery,
|
||||
ATTR_DISCOVERED: {}})
|
||||
|
||||
ISY.auto_update = True
|
||||
return True
|
||||
|
||||
|
||||
def stop(event):
|
||||
""" Cleanup the ISY subscription. """
|
||||
ISY.auto_update = False
|
||||
|
||||
|
||||
class ISYDeviceABC(ToggleEntity):
|
||||
""" Abstract Class for an ISY device. """
|
||||
|
||||
_attrs = {}
|
||||
_onattrs = []
|
||||
_states = []
|
||||
_dtype = None
|
||||
_domain = None
|
||||
_name = None
|
||||
|
||||
def __init__(self, node):
|
||||
# setup properties
|
||||
self.node = node
|
||||
self.hidden = HIDDEN_STRING in self.raw_name
|
||||
|
||||
# track changes
|
||||
self._change_handler = self.node.status. \
|
||||
subscribe('changed', self.on_update)
|
||||
|
||||
def __del__(self):
|
||||
""" cleanup subscriptions because it is the right thing to do. """
|
||||
self._change_handler.unsubscribe()
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
""" Returns the domain of the entity. """
|
||||
return self._domain
|
||||
|
||||
@property
|
||||
def dtype(self):
|
||||
""" Returns the data type of the entity (binary or analog). """
|
||||
if self._dtype in ['analog', 'binary']:
|
||||
return self._dtype
|
||||
return 'binary' if self.unit_of_measurement is None else 'analog'
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" Tells Home Assistant not to poll this entity. """
|
||||
return False
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
""" Returns the unclean value from the controller. """
|
||||
# pylint: disable=protected-access
|
||||
return self.node.status._val
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Returns the state attributes for the node. """
|
||||
attr = {ATTR_FRIENDLY_NAME: self.name}
|
||||
for name, prop in self._attrs.items():
|
||||
attr[name] = getattr(self, prop)
|
||||
attr = self._attr_filter(attr)
|
||||
return attr
|
||||
|
||||
def _attr_filter(self, attr):
|
||||
""" Placeholder for attribute filters. """
|
||||
# pylint: disable=no-self-use
|
||||
return attr
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
""" Returns the id of this ISY sensor. """
|
||||
# pylint: disable=protected-access
|
||||
return self.node._id
|
||||
|
||||
@property
|
||||
def raw_name(self):
|
||||
""" Returns the unclean node name. """
|
||||
return str(self._name) \
|
||||
if self._name is not None else str(self.node.name)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the cleaned name of the node. """
|
||||
return self.raw_name.replace(HIDDEN_STRING, '').strip() \
|
||||
.replace('_', ' ')
|
||||
|
||||
def update(self):
|
||||
""" Update state of the sensor. """
|
||||
# ISY objects are automatically updated by the ISY's event stream
|
||||
pass
|
||||
|
||||
def on_update(self, event):
|
||||
""" Handles the update received event. """
|
||||
self.update_ha_state()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" Returns boolean response if the node is on. """
|
||||
return bool(self.value)
|
||||
|
||||
@property
|
||||
def is_open(self):
|
||||
""" Returns boolean respons if the node is open. On = Open. """
|
||||
return self.is_on
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the node. """
|
||||
if len(self._states) > 0:
|
||||
return self._states[0] if self.is_on else self._states[1]
|
||||
return self.value
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turns the device on. """
|
||||
if self.domain is not 'sensor':
|
||||
attrs = [kwargs.get(name) for name in self._onattrs]
|
||||
self.node.on(*attrs)
|
||||
else:
|
||||
_LOGGER.error('ISY cannot turn on sensors.')
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turns the device off. """
|
||||
if self.domain is not 'sensor':
|
||||
self.node.off()
|
||||
else:
|
||||
_LOGGER.error('ISY cannot turn off sensors.')
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
""" Returns the defined units of measurement or None. """
|
||||
try:
|
||||
return self.node.units
|
||||
except AttributeError:
|
||||
return None
|
||||
@@ -6,76 +6,82 @@ Provides functionality to emulate keyboard presses on host machine.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import homeassistant.components as components
|
||||
from homeassistant.const import (
|
||||
SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE,
|
||||
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
SERVICE_MEDIA_PLAY_PAUSE)
|
||||
|
||||
|
||||
DOMAIN = "keyboard"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['pyuserinput==0.1.9']
|
||||
|
||||
|
||||
def volume_up(bus):
|
||||
def volume_up(hass):
|
||||
""" Press the keyboard button for volume up. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_UP)
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_UP)
|
||||
|
||||
|
||||
def volume_down(bus):
|
||||
def volume_down(hass):
|
||||
""" Press the keyboard button for volume down. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_DOWN)
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN)
|
||||
|
||||
|
||||
def volume_mute(bus):
|
||||
def volume_mute(hass):
|
||||
""" Press the keyboard button for muting volume. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_MUTE)
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE)
|
||||
|
||||
|
||||
def media_play_pause(bus):
|
||||
def media_play_pause(hass):
|
||||
""" Press the keyboard button for play/pause. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_PLAY_PAUSE)
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE)
|
||||
|
||||
|
||||
def media_next_track(bus):
|
||||
def media_next_track(hass):
|
||||
""" Press the keyboard button for next track. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_NEXT_TRACK)
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK)
|
||||
|
||||
|
||||
def media_prev_track(bus):
|
||||
def media_prev_track(hass):
|
||||
""" Press the keyboard button for prev track. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_PREV_TRACK)
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK)
|
||||
|
||||
|
||||
def setup(bus):
|
||||
def setup(hass, config):
|
||||
""" Listen for keyboard events. """
|
||||
try:
|
||||
import pykeyboard
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).exception(
|
||||
"MediaButtons: Error while importing dependency PyUserInput.")
|
||||
"Error while importing dependency PyUserInput.")
|
||||
|
||||
return False
|
||||
|
||||
keyboard = pykeyboard.PyKeyboard()
|
||||
keyboard.special_key_assignment()
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_UP,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_up_key))
|
||||
hass.services.register(DOMAIN, SERVICE_VOLUME_UP,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_up_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_DOWN,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_down_key))
|
||||
hass.services.register(DOMAIN, SERVICE_VOLUME_DOWN,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_down_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_MUTE,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_mute_key))
|
||||
hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_mute_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_PLAY_PAUSE,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_play_pause_key))
|
||||
hass.services.register(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_play_pause_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_NEXT_TRACK,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_next_track_key))
|
||||
hass.services.register(DOMAIN, SERVICE_MEDIA_NEXT_TRACK,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_next_track_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_PREV_TRACK,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_prev_track_key))
|
||||
hass.services.register(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_prev_track_key))
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
homeassistant.components.light
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to interact with lights.
|
||||
|
||||
@@ -49,30 +49,28 @@ Supports following parameters:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
from collections import namedtuple
|
||||
import os
|
||||
import csv
|
||||
|
||||
import homeassistant as ha
|
||||
from homeassistant.components import group, discovery, wink, isy994
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.const import (
|
||||
STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID)
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
import homeassistant.util as util
|
||||
from homeassistant.components import (group, extract_entity_ids,
|
||||
STATE_ON, STATE_OFF,
|
||||
SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
||||
ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME)
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
|
||||
DOMAIN = "light"
|
||||
DEPENDENCIES = []
|
||||
SCAN_INTERVAL = 30
|
||||
|
||||
GROUP_NAME_ALL_LIGHTS = 'all_lights'
|
||||
ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format(
|
||||
GROUP_NAME_ALL_LIGHTS)
|
||||
GROUP_NAME_ALL_LIGHTS = 'all lights'
|
||||
ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format('all_lights')
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
# integer that represents transition time in seconds to make change
|
||||
ATTR_TRANSITION = "transition"
|
||||
|
||||
@@ -86,157 +84,107 @@ ATTR_BRIGHTNESS = "brightness"
|
||||
# String representing a profile (built-in ones or external defined)
|
||||
ATTR_PROFILE = "profile"
|
||||
|
||||
# If the light should flash, can be FLASH_SHORT or FLASH_LONG
|
||||
ATTR_FLASH = "flash"
|
||||
FLASH_SHORT = "short"
|
||||
FLASH_LONG = "long"
|
||||
|
||||
# Apply an effect to the light, can be EFFECT_COLORLOOP
|
||||
ATTR_EFFECT = "effect"
|
||||
EFFECT_COLORLOOP = "colorloop"
|
||||
|
||||
LIGHT_PROFILES_FILE = "light_profiles.csv"
|
||||
|
||||
# Maps discovered services to their platforms
|
||||
DISCOVERY_PLATFORMS = {
|
||||
wink.DISCOVER_LIGHTS: 'wink',
|
||||
isy994.DISCOVER_LIGHTS: 'isy994',
|
||||
discovery.SERVICE_HUE: 'hue',
|
||||
}
|
||||
|
||||
def is_on(statemachine, entity_id=None):
|
||||
PROP_TO_ATTR = {
|
||||
'brightness': ATTR_BRIGHTNESS,
|
||||
'color_xy': ATTR_XY_COLOR,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_on(hass, entity_id=None):
|
||||
""" Returns if the lights are on based on the statemachine. """
|
||||
entity_id = entity_id or ENTITY_ID_ALL_LIGHTS
|
||||
|
||||
return statemachine.is_state(entity_id, STATE_ON)
|
||||
return hass.states.is_state(entity_id, STATE_ON)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def turn_on(bus, entity_id=None, transition=None, brightness=None,
|
||||
rgb_color=None, xy_color=None, profile=None):
|
||||
def turn_on(hass, entity_id=None, transition=None, brightness=None,
|
||||
rgb_color=None, xy_color=None, profile=None, flash=None,
|
||||
effect=None):
|
||||
""" Turns all or specified light on. """
|
||||
data = {}
|
||||
data = {
|
||||
key: value for key, value in [
|
||||
(ATTR_ENTITY_ID, entity_id),
|
||||
(ATTR_PROFILE, profile),
|
||||
(ATTR_TRANSITION, transition),
|
||||
(ATTR_BRIGHTNESS, brightness),
|
||||
(ATTR_RGB_COLOR, rgb_color),
|
||||
(ATTR_XY_COLOR, xy_color),
|
||||
(ATTR_FLASH, flash),
|
||||
(ATTR_EFFECT, effect),
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
if profile:
|
||||
data[ATTR_PROFILE] = profile
|
||||
|
||||
if transition is not None:
|
||||
data[ATTR_TRANSITION] = transition
|
||||
|
||||
if brightness is not None:
|
||||
data[ATTR_BRIGHTNESS] = brightness
|
||||
|
||||
if rgb_color:
|
||||
data[ATTR_RGB_COLOR] = rgb_color
|
||||
|
||||
if xy_color:
|
||||
data[ATTR_XY_COLOR] = xy_color
|
||||
|
||||
bus.call_service(DOMAIN, SERVICE_TURN_ON, data)
|
||||
hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
|
||||
|
||||
|
||||
def turn_off(bus, entity_id=None, transition=None):
|
||||
def turn_off(hass, entity_id=None, transition=None):
|
||||
""" Turns all or specified light off. """
|
||||
data = {}
|
||||
data = {
|
||||
key: value for key, value in [
|
||||
(ATTR_ENTITY_ID, entity_id),
|
||||
(ATTR_TRANSITION, transition),
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
if transition is not None:
|
||||
data[ATTR_TRANSITION] = transition
|
||||
|
||||
bus.call_service(DOMAIN, SERVICE_TURN_OFF, data)
|
||||
hass.services.call(DOMAIN, SERVICE_TURN_OFF, data)
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-locals
|
||||
def setup(bus, statemachine, light_control):
|
||||
def setup(hass, config):
|
||||
""" Exposes light control via statemachine and services. """
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ent_to_light = {}
|
||||
light_to_ent = {}
|
||||
|
||||
def _update_light_state(light_id, light_state):
|
||||
""" Update statemachine based on the LightState passed in. """
|
||||
name = light_control.get_name(light_id) or "Unknown Light"
|
||||
|
||||
try:
|
||||
entity_id = light_to_ent[light_id]
|
||||
except KeyError:
|
||||
# We have not seen this light before, set it up
|
||||
|
||||
# Create entity id
|
||||
logger.info(u"Found new light {}".format(name))
|
||||
|
||||
entity_id = util.ensure_unique_string(
|
||||
ENTITY_ID_FORMAT.format(util.slugify(name)),
|
||||
ent_to_light.keys())
|
||||
|
||||
ent_to_light[entity_id] = light_id
|
||||
light_to_ent[light_id] = entity_id
|
||||
|
||||
state_attr = {ATTR_FRIENDLY_NAME: name}
|
||||
|
||||
if light_state.on:
|
||||
state = STATE_ON
|
||||
|
||||
if light_state.brightness:
|
||||
state_attr[ATTR_BRIGHTNESS] = light_state.brightness
|
||||
|
||||
if light_state.color:
|
||||
state_attr[ATTR_XY_COLOR] = light_state.color
|
||||
|
||||
else:
|
||||
state = STATE_OFF
|
||||
|
||||
statemachine.set_state(entity_id, state, state_attr)
|
||||
|
||||
def update_light_state(light_id):
|
||||
""" Update the state of specified light. """
|
||||
_update_light_state(light_id, light_control.get_state(light_id))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def update_lights_state(time, force_reload=False):
|
||||
""" Update the state of all the lights. """
|
||||
|
||||
# First time this method gets called, force_reload should be True
|
||||
if (force_reload or
|
||||
datetime.now() - update_lights_state.last_updated >
|
||||
MIN_TIME_BETWEEN_SCANS):
|
||||
|
||||
logger.info("Updating light status")
|
||||
update_lights_state.last_updated = datetime.now()
|
||||
|
||||
for light_id, light_state in light_control.get_states().items():
|
||||
_update_light_state(light_id, light_state)
|
||||
|
||||
# Update light state and discover lights for tracking the group
|
||||
update_lights_state(None, True)
|
||||
|
||||
if len(ent_to_light) == 0:
|
||||
logger.error("No lights found")
|
||||
return False
|
||||
|
||||
# Track all lights in a group
|
||||
group.setup(bus, statemachine,
|
||||
GROUP_NAME_ALL_LIGHTS, light_to_ent.values())
|
||||
component = EntityComponent(
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS,
|
||||
GROUP_NAME_ALL_LIGHTS)
|
||||
component.setup(config)
|
||||
|
||||
# Load built-in profiles and custom profiles
|
||||
profile_paths = [os.path.dirname(__file__), os.getcwd()]
|
||||
profile_paths = [os.path.join(os.path.dirname(__file__),
|
||||
LIGHT_PROFILES_FILE),
|
||||
hass.config.path(LIGHT_PROFILES_FILE)]
|
||||
profiles = {}
|
||||
|
||||
for dir_path in profile_paths:
|
||||
file_path = os.path.join(dir_path, LIGHT_PROFILES_FILE)
|
||||
for profile_path in profile_paths:
|
||||
if not os.path.isfile(profile_path):
|
||||
continue
|
||||
with open(profile_path) as inp:
|
||||
reader = csv.reader(inp)
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
with open(file_path, 'rb') as inp:
|
||||
reader = csv.reader(inp)
|
||||
# Skip the header
|
||||
next(reader, None)
|
||||
|
||||
# Skip the header
|
||||
next(reader, None)
|
||||
try:
|
||||
for profile_id, color_x, color_y, brightness in reader:
|
||||
profiles[profile_id] = (float(color_x), float(color_y),
|
||||
int(brightness))
|
||||
except ValueError:
|
||||
# ValueError if not 4 values per row
|
||||
# ValueError if convert to float/int failed
|
||||
_LOGGER.error(
|
||||
"Error parsing light profiles from %s", profile_path)
|
||||
|
||||
try:
|
||||
for profile_id, color_x, color_y, brightness in reader:
|
||||
profiles[profile_id] = (float(color_x), float(color_y),
|
||||
int(brightness))
|
||||
|
||||
except ValueError:
|
||||
# ValueError if not 4 values per row
|
||||
# ValueError if convert to float/int failed
|
||||
logger.error(
|
||||
"Error parsing light profiles from {}".format(
|
||||
file_path))
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
def handle_light_service(service):
|
||||
""" Hande a turn light on or off service call. """
|
||||
@@ -244,215 +192,133 @@ def setup(bus, statemachine, light_control):
|
||||
dat = service.data
|
||||
|
||||
# Convert the entity ids to valid light ids
|
||||
light_ids = [ent_to_light[entity_id] for entity_id
|
||||
in extract_entity_ids(statemachine, service)
|
||||
if entity_id in ent_to_light]
|
||||
target_lights = component.extract_from_service(service)
|
||||
|
||||
if not light_ids:
|
||||
light_ids = ent_to_light.values()
|
||||
params = {}
|
||||
|
||||
transition = util.convert(dat.get(ATTR_TRANSITION), int)
|
||||
|
||||
if transition is not None:
|
||||
params[ATTR_TRANSITION] = transition
|
||||
|
||||
if service.service == SERVICE_TURN_OFF:
|
||||
light_control.turn_light_off(light_ids, transition)
|
||||
for light in target_lights:
|
||||
light.turn_off(**params)
|
||||
|
||||
else:
|
||||
# Processing extra data for turn light on request
|
||||
for light in target_lights:
|
||||
if light.should_poll:
|
||||
light.update_ha_state(True)
|
||||
return
|
||||
|
||||
# We process the profile first so that we get the desired
|
||||
# behavior that extra service data attributes overwrite
|
||||
# profile values
|
||||
profile = profiles.get(dat.get(ATTR_PROFILE))
|
||||
# Processing extra data for turn light on request
|
||||
|
||||
if profile:
|
||||
color = profile[0:2]
|
||||
bright = profile[2]
|
||||
else:
|
||||
color = None
|
||||
bright = None
|
||||
# We process the profile first so that we get the desired
|
||||
# behavior that extra service data attributes overwrite
|
||||
# profile values
|
||||
profile = profiles.get(dat.get(ATTR_PROFILE))
|
||||
|
||||
if ATTR_BRIGHTNESS in dat:
|
||||
bright = util.convert(dat.get(ATTR_BRIGHTNESS), int)
|
||||
if profile:
|
||||
*params[ATTR_XY_COLOR], params[ATTR_BRIGHTNESS] = profile
|
||||
|
||||
if ATTR_XY_COLOR in dat:
|
||||
try:
|
||||
# xy_color should be a list containing 2 floats
|
||||
xy_color = [float(val) for val in dat.get(ATTR_XY_COLOR)]
|
||||
if ATTR_BRIGHTNESS in dat:
|
||||
# We pass in the old value as the default parameter if parsing
|
||||
# of the new one goes wrong.
|
||||
params[ATTR_BRIGHTNESS] = util.convert(
|
||||
dat.get(ATTR_BRIGHTNESS), int, params.get(ATTR_BRIGHTNESS))
|
||||
|
||||
if len(xy_color) == 2:
|
||||
color = xy_color
|
||||
if ATTR_XY_COLOR in dat:
|
||||
try:
|
||||
# xy_color should be a list containing 2 floats
|
||||
xycolor = dat.get(ATTR_XY_COLOR)
|
||||
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if dat[ATTR_XY_COLOR] is not iterable
|
||||
# ValueError if value could not be converted to float
|
||||
pass
|
||||
# Without this check, a xycolor with value '99' would work
|
||||
if not isinstance(xycolor, str):
|
||||
params[ATTR_XY_COLOR] = [float(val) for val in xycolor]
|
||||
|
||||
if ATTR_RGB_COLOR in dat:
|
||||
try:
|
||||
# rgb_color should be a list containing 3 ints
|
||||
rgb_color = [int(val) for val in dat.get(ATTR_RGB_COLOR)]
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if xy_color is not iterable
|
||||
# ValueError if value could not be converted to float
|
||||
pass
|
||||
|
||||
if len(rgb_color) == 3:
|
||||
color = util.color_RGB_to_xy(rgb_color[0],
|
||||
rgb_color[1],
|
||||
rgb_color[2])
|
||||
if ATTR_RGB_COLOR in dat:
|
||||
try:
|
||||
# rgb_color should be a list containing 3 ints
|
||||
rgb_color = dat.get(ATTR_RGB_COLOR)
|
||||
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if dat[ATTR_RGB_COLOR] is not iterable
|
||||
# ValueError if not all values can be converted to int
|
||||
pass
|
||||
if len(rgb_color) == 3:
|
||||
params[ATTR_XY_COLOR] = \
|
||||
color_util.color_RGB_to_xy(int(rgb_color[0]),
|
||||
int(rgb_color[1]),
|
||||
int(rgb_color[2]))
|
||||
|
||||
light_control.turn_light_on(light_ids, transition, bright, color)
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if rgb_color is not iterable
|
||||
# ValueError if not all values can be converted to int
|
||||
pass
|
||||
|
||||
# Update state of lights touched. If there was only 1 light selected
|
||||
# then just update that light else update all
|
||||
if len(light_ids) == 1:
|
||||
update_light_state(light_ids[0])
|
||||
else:
|
||||
update_lights_state(None, True)
|
||||
if ATTR_FLASH in dat:
|
||||
if dat[ATTR_FLASH] == FLASH_SHORT:
|
||||
params[ATTR_FLASH] = FLASH_SHORT
|
||||
|
||||
# Update light state every 30 seconds
|
||||
ha.track_time_change(bus, update_lights_state, second=[0, 30])
|
||||
elif dat[ATTR_FLASH] == FLASH_LONG:
|
||||
params[ATTR_FLASH] = FLASH_LONG
|
||||
|
||||
if ATTR_EFFECT in dat:
|
||||
if dat[ATTR_EFFECT] == EFFECT_COLORLOOP:
|
||||
params[ATTR_EFFECT] = EFFECT_COLORLOOP
|
||||
|
||||
for light in target_lights:
|
||||
light.turn_on(**params)
|
||||
|
||||
for light in target_lights:
|
||||
if light.should_poll:
|
||||
light.update_ha_state(True)
|
||||
|
||||
# Listen for light on and light off service calls
|
||||
bus.register_service(DOMAIN, SERVICE_TURN_ON,
|
||||
handle_light_service)
|
||||
descriptions = load_yaml_config_file(
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_light_service,
|
||||
descriptions.get(SERVICE_TURN_ON))
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_TURN_OFF,
|
||||
handle_light_service)
|
||||
hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_light_service,
|
||||
descriptions.get(SERVICE_TURN_OFF))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
LightState = namedtuple("LightState", ['on', 'brightness', 'color'])
|
||||
class Light(ToggleEntity):
|
||||
""" Represents a light within Home Assistant. """
|
||||
# pylint: disable=no-self-use
|
||||
|
||||
|
||||
def _hue_to_light_state(info):
|
||||
""" Helper method to convert a Hue state to a LightState. """
|
||||
try:
|
||||
return LightState(info['state']['reachable'] and info['state']['on'],
|
||||
info['state']['bri'], info['state']['xy'])
|
||||
except KeyError:
|
||||
# KeyError if one of the keys didn't exist
|
||||
@property
|
||||
def brightness(self):
|
||||
""" Brightness of this light between 0..255. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def color_xy(self):
|
||||
""" XY color value [float, float]. """
|
||||
return None
|
||||
|
||||
class HueLightControl(object):
|
||||
""" Class to interface with the Hue light system. """
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
""" Returns device specific state attributes. """
|
||||
return None
|
||||
|
||||
def __init__(self, host=None):
|
||||
logger = logging.getLogger(__name__)
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Returns optional state attributes. """
|
||||
data = {}
|
||||
|
||||
try:
|
||||
import phue
|
||||
except ImportError:
|
||||
logger.exception(
|
||||
"HueLightControl:Error while importing dependency phue.")
|
||||
if self.is_on:
|
||||
for prop, attr in PROP_TO_ATTR.items():
|
||||
value = getattr(self, prop)
|
||||
if value:
|
||||
data[attr] = value
|
||||
|
||||
self.success_init = False
|
||||
device_attr = self.device_state_attributes
|
||||
|
||||
return
|
||||
if device_attr is not None:
|
||||
data.update(device_attr)
|
||||
|
||||
try:
|
||||
self._bridge = phue.Bridge(host)
|
||||
except socket.error: # Error connecting using Phue
|
||||
logger.exception((
|
||||
"HueLightControl:Error while connecting to the bridge. "
|
||||
"Is phue registered?"))
|
||||
|
||||
self.success_init = False
|
||||
|
||||
return
|
||||
|
||||
# Dict mapping light_id to name
|
||||
self._lights = {}
|
||||
self._update_lights()
|
||||
|
||||
if len(self._lights) == 0:
|
||||
logger.error("HueLightControl:Could not find any lights. ")
|
||||
|
||||
self.success_init = False
|
||||
else:
|
||||
self.success_init = True
|
||||
|
||||
def _update_lights(self):
|
||||
""" Helper method to update the known names from Hue. """
|
||||
try:
|
||||
self._lights = {int(item[0]): item[1]['name'] for item
|
||||
in self._bridge.get_light().items()}
|
||||
|
||||
except (socket.error, KeyError):
|
||||
# socket.error because sometimes we cannot reach Hue
|
||||
# KeyError if we got unexpected data
|
||||
# We don't do anything, keep old values
|
||||
pass
|
||||
|
||||
def get_name(self, light_id):
|
||||
""" Return name for specified light_id or None if no name known. """
|
||||
if not light_id in self._lights:
|
||||
self._update_lights()
|
||||
|
||||
return self._lights.get(light_id)
|
||||
|
||||
def get_state(self, light_id):
|
||||
""" Return a LightState representing light light_id. """
|
||||
try:
|
||||
info = self._bridge.get_light(light_id)
|
||||
|
||||
return _hue_to_light_state(info)
|
||||
|
||||
except socket.error:
|
||||
# socket.error when we cannot reach Hue
|
||||
return None
|
||||
|
||||
def get_states(self):
|
||||
""" Return a dict with id mapped to LightState objects. """
|
||||
states = {}
|
||||
|
||||
try:
|
||||
api = self._bridge.get_api()
|
||||
|
||||
except socket.error:
|
||||
# socket.error when we cannot reach Hue
|
||||
return states
|
||||
|
||||
api_states = api.get('lights')
|
||||
|
||||
if not isinstance(api_states, dict):
|
||||
return states
|
||||
|
||||
for light_id, info in api_states.items():
|
||||
state = _hue_to_light_state(info)
|
||||
|
||||
if state:
|
||||
states[int(light_id)] = state
|
||||
|
||||
return states
|
||||
|
||||
def turn_light_on(self, light_ids, transition, brightness, xy_color):
|
||||
""" Turn the specified or all lights on. """
|
||||
command = {'on': True}
|
||||
|
||||
if transition is not None:
|
||||
# Transition time is in 1/10th seconds and cannot exceed
|
||||
# 900 seconds.
|
||||
command['transitiontime'] = min(9000, transition * 10)
|
||||
|
||||
if brightness is not None:
|
||||
command['bri'] = brightness
|
||||
|
||||
if xy_color:
|
||||
command['xy'] = xy_color
|
||||
|
||||
self._bridge.set_light(light_ids, command)
|
||||
|
||||
def turn_light_off(self, light_ids, transition):
|
||||
""" Turn the specified or all lights off. """
|
||||
command = {'on': False}
|
||||
|
||||
if transition is not None:
|
||||
# Transition time is in 1/10th seconds and cannot exceed
|
||||
# 900 seconds.
|
||||
command['transitiontime'] = min(9000, transition * 10)
|
||||
|
||||
self._bridge.set_light(light_ids, command)
|
||||
return data
|
||||
|
||||
77
homeassistant/components/light/demo.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
homeassistant.components.light.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Demo platform that implements lights.
|
||||
|
||||
"""
|
||||
import random
|
||||
|
||||
from homeassistant.components.light import (
|
||||
Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR)
|
||||
|
||||
|
||||
LIGHT_COLORS = [
|
||||
[0.368, 0.180],
|
||||
[0.460, 0.470],
|
||||
]
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Find and return demo lights. """
|
||||
add_devices_callback([
|
||||
DemoLight("Bed Light", False),
|
||||
DemoLight("Ceiling Lights", True, LIGHT_COLORS[0]),
|
||||
DemoLight("Kitchen Lights", True, LIGHT_COLORS[1])
|
||||
])
|
||||
|
||||
|
||||
class DemoLight(Light):
|
||||
""" Provides a demo switch. """
|
||||
def __init__(self, name, state, xy=None, brightness=180):
|
||||
self._name = name
|
||||
self._state = state
|
||||
self._xy = xy or random.choice(LIGHT_COLORS)
|
||||
self._brightness = brightness
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" No polling needed for a demo light. """
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device if any. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
""" Brightness of this light between 0..255. """
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def color_xy(self):
|
||||
""" XY color value. """
|
||||
return self._xy
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if device is on. """
|
||||
return self._state
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turn the device on. """
|
||||
self._state = True
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
self._xy = kwargs[ATTR_XY_COLOR]
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
self.update_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turn the device off. """
|
||||
self._state = False
|
||||
self.update_ha_state()
|
||||
222
homeassistant/components/light/hue.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
homeassistant.components.light.hue
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for Hue lights.
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant.loader import get_component
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME
|
||||
from homeassistant.components.light import (
|
||||
Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR, ATTR_TRANSITION,
|
||||
ATTR_FLASH, FLASH_LONG, FLASH_SHORT, ATTR_EFFECT,
|
||||
EFFECT_COLORLOOP)
|
||||
|
||||
REQUIREMENTS = ['phue==0.8']
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
|
||||
|
||||
PHUE_CONFIG_FILE = "phue.conf"
|
||||
|
||||
|
||||
# Map ip to request id for configuring
|
||||
_CONFIGURING = {}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Gets the Hue lights. """
|
||||
try:
|
||||
# pylint: disable=unused-variable
|
||||
import phue # noqa
|
||||
except ImportError:
|
||||
_LOGGER.exception("Error while importing dependency phue.")
|
||||
|
||||
return
|
||||
|
||||
if discovery_info is not None:
|
||||
host = urlparse(discovery_info[1]).hostname
|
||||
else:
|
||||
host = config.get(CONF_HOST, None)
|
||||
|
||||
# Only act if we are not already configuring this host
|
||||
if host in _CONFIGURING:
|
||||
return
|
||||
|
||||
setup_bridge(host, hass, add_devices_callback)
|
||||
|
||||
|
||||
def setup_bridge(host, hass, add_devices_callback):
|
||||
""" Setup a phue bridge based on host parameter. """
|
||||
import phue
|
||||
|
||||
try:
|
||||
bridge = phue.Bridge(
|
||||
host,
|
||||
config_file_path=hass.config.path(PHUE_CONFIG_FILE))
|
||||
except ConnectionRefusedError: # Wrong host was given
|
||||
_LOGGER.exception("Error connecting to the Hue bridge at %s", host)
|
||||
|
||||
return
|
||||
|
||||
except phue.PhueRegistrationException:
|
||||
_LOGGER.warning("Connected to Hue at %s but not registered.", host)
|
||||
|
||||
request_configuration(host, hass, add_devices_callback)
|
||||
|
||||
return
|
||||
|
||||
# If we came here and configuring this host, mark as done
|
||||
if host in _CONFIGURING:
|
||||
request_id = _CONFIGURING.pop(host)
|
||||
|
||||
configurator = get_component('configurator')
|
||||
|
||||
configurator.request_done(request_id)
|
||||
|
||||
lights = {}
|
||||
|
||||
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
||||
def update_lights():
|
||||
""" Updates the Hue light objects with latest info from the bridge. """
|
||||
try:
|
||||
api = bridge.get_api()
|
||||
except socket.error:
|
||||
# socket.error when we cannot reach Hue
|
||||
_LOGGER.exception("Cannot reach the bridge")
|
||||
return
|
||||
|
||||
api_states = api.get('lights')
|
||||
|
||||
if not isinstance(api_states, dict):
|
||||
_LOGGER.error("Got unexpected result from Hue API")
|
||||
return
|
||||
|
||||
new_lights = []
|
||||
|
||||
for light_id, info in api_states.items():
|
||||
if light_id not in lights:
|
||||
lights[light_id] = HueLight(int(light_id), info,
|
||||
bridge, update_lights)
|
||||
new_lights.append(lights[light_id])
|
||||
else:
|
||||
lights[light_id].info = info
|
||||
|
||||
if new_lights:
|
||||
add_devices_callback(new_lights)
|
||||
|
||||
update_lights()
|
||||
|
||||
|
||||
def request_configuration(host, hass, add_devices_callback):
|
||||
""" Request configuration steps from the user. """
|
||||
configurator = get_component('configurator')
|
||||
|
||||
# We got an error if this method is called while we are configuring
|
||||
if host in _CONFIGURING:
|
||||
configurator.notify_errors(
|
||||
_CONFIGURING[host], "Failed to register, please try again.")
|
||||
|
||||
return
|
||||
|
||||
def hue_configuration_callback(data):
|
||||
""" Actions to do when our configuration callback is called. """
|
||||
setup_bridge(host, hass, add_devices_callback)
|
||||
|
||||
_CONFIGURING[host] = configurator.request_config(
|
||||
hass, "Philips Hue", hue_configuration_callback,
|
||||
description=("Press the button on the bridge to register Philips Hue "
|
||||
"with Home Assistant."),
|
||||
description_image="/static/images/config_philips_hue.jpg",
|
||||
submit_caption="I have pressed the button"
|
||||
)
|
||||
|
||||
|
||||
class HueLight(Light):
|
||||
""" Represents a Hue light """
|
||||
|
||||
def __init__(self, light_id, info, bridge, update_lights):
|
||||
self.light_id = light_id
|
||||
self.info = info
|
||||
self.bridge = bridge
|
||||
self.update_lights = update_lights
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
""" Returns the id of this Hue light """
|
||||
return "{}.{}".format(
|
||||
self.__class__, self.info.get('uniqueid', self.name))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Get the mame of the Hue light. """
|
||||
return self.info.get('name', DEVICE_DEFAULT_NAME)
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
""" Brightness of this light between 0..255. """
|
||||
return self.info['state']['bri']
|
||||
|
||||
@property
|
||||
def color_xy(self):
|
||||
""" XY color value. """
|
||||
return self.info['state'].get('xy')
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if device is on. """
|
||||
self.update_lights()
|
||||
|
||||
return self.info['state']['reachable'] and self.info['state']['on']
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turn the specified or all lights on. """
|
||||
command = {'on': True}
|
||||
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
# Transition time is in 1/10th seconds and cannot exceed
|
||||
# 900 seconds.
|
||||
command['transitiontime'] = min(9000, kwargs[ATTR_TRANSITION] * 10)
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
command['bri'] = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
command['xy'] = kwargs[ATTR_XY_COLOR]
|
||||
|
||||
flash = kwargs.get(ATTR_FLASH)
|
||||
|
||||
if flash == FLASH_LONG:
|
||||
command['alert'] = 'lselect'
|
||||
elif flash == FLASH_SHORT:
|
||||
command['alert'] = 'select'
|
||||
else:
|
||||
command['alert'] = 'none'
|
||||
|
||||
effect = kwargs.get(ATTR_EFFECT)
|
||||
|
||||
if effect == EFFECT_COLORLOOP:
|
||||
command['effect'] = 'colorloop'
|
||||
else:
|
||||
command['effect'] = 'none'
|
||||
|
||||
self.bridge.set_light(self.light_id, command)
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turn the specified or all lights off. """
|
||||
command = {'on': False}
|
||||
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
# Transition time is in 1/10th seconds and cannot exceed
|
||||
# 900 seconds.
|
||||
command['transitiontime'] = min(9000, kwargs[ATTR_TRANSITION] * 10)
|
||||
|
||||
self.bridge.set_light(self.light_id, command)
|
||||
|
||||
def update(self):
|
||||
""" Synchronize state with bridge. """
|
||||
self.update_lights(no_throttle=True)
|
||||
46
homeassistant/components/light/isy994.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
homeassistant.components.light.isy994
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for ISY994 lights.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.isy994 import (ISYDeviceABC, ISY, SENSOR_STRING,
|
||||
HIDDEN_STRING)
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS
|
||||
from homeassistant.const import STATE_ON, STATE_OFF
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the ISY994 platform. """
|
||||
logger = logging.getLogger(__name__)
|
||||
devs = []
|
||||
# verify connection
|
||||
if ISY is None or not ISY.connected:
|
||||
logger.error('A connection has not been made to the ISY controller.')
|
||||
return False
|
||||
|
||||
# import dimmable nodes
|
||||
for (path, node) in ISY.nodes:
|
||||
if node.dimmable and SENSOR_STRING not in node.name:
|
||||
if HIDDEN_STRING in path:
|
||||
node.name += HIDDEN_STRING
|
||||
devs.append(ISYLightDevice(node))
|
||||
|
||||
add_devices(devs)
|
||||
|
||||
|
||||
class ISYLightDevice(ISYDeviceABC):
|
||||
""" Represents as ISY light. """
|
||||
|
||||
_domain = 'light'
|
||||
_dtype = 'analog'
|
||||
_attrs = {ATTR_BRIGHTNESS: 'value'}
|
||||
_onattrs = [ATTR_BRIGHTNESS]
|
||||
_states = [STATE_ON, STATE_OFF]
|
||||
|
||||
def _attr_filter(self, attr):
|
||||
""" Filter brightness out of entity while off. """
|
||||
if ATTR_BRIGHTNESS in attr and not self.is_on:
|
||||
del attr[ATTR_BRIGHTNESS]
|
||||
return attr
|
||||
194
homeassistant/components/light/limitlessled.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
homeassistant.components.light.limitlessled
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Support for LimitlessLED bulbs, also known as...
|
||||
|
||||
- EasyBulb
|
||||
- AppLight
|
||||
- AppLamp
|
||||
- MiLight
|
||||
- LEDme
|
||||
- dekolight
|
||||
- iLight
|
||||
|
||||
Configuration:
|
||||
|
||||
To use limitlessled you will need to add the following to your
|
||||
configuration.yaml file.
|
||||
|
||||
light:
|
||||
platform: limitlessled
|
||||
bridges:
|
||||
- host: 192.168.1.10
|
||||
group_1_name: Living Room
|
||||
group_2_name: Bedroom
|
||||
group_3_name: Office
|
||||
group_3_type: white
|
||||
group_4_name: Kitchen
|
||||
- host: 192.168.1.11
|
||||
group_2_name: Basement
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import DEVICE_DEFAULT_NAME
|
||||
from homeassistant.components.light import (Light, ATTR_BRIGHTNESS,
|
||||
ATTR_XY_COLOR)
|
||||
from homeassistant.util.color import color_RGB_to_xy
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['ledcontroller==1.1.0']
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Gets the LimitlessLED lights. """
|
||||
import ledcontroller
|
||||
|
||||
# Handle old configuration format:
|
||||
bridges = config.get('bridges', [config])
|
||||
|
||||
for bridge_id, bridge in enumerate(bridges):
|
||||
bridge['id'] = bridge_id
|
||||
|
||||
pool = ledcontroller.LedControllerPool([x['host'] for x in bridges])
|
||||
|
||||
lights = []
|
||||
for bridge in bridges:
|
||||
for i in range(1, 5):
|
||||
name_key = 'group_%d_name' % i
|
||||
if name_key in bridge:
|
||||
group_type = bridge.get('group_%d_type' % i, 'rgbw')
|
||||
lights.append(LimitlessLED.factory(pool, bridge['id'], i,
|
||||
bridge[name_key],
|
||||
group_type))
|
||||
|
||||
add_devices_callback(lights)
|
||||
|
||||
|
||||
class LimitlessLED(Light):
|
||||
""" Represents a LimitlessLED light """
|
||||
|
||||
@staticmethod
|
||||
def factory(pool, controller_id, group, name, group_type):
|
||||
''' Construct a Limitless LED of the appropriate type '''
|
||||
if group_type == 'white':
|
||||
return WhiteLimitlessLED(pool, controller_id, group, name)
|
||||
elif group_type == 'rgbw':
|
||||
return RGBWLimitlessLED(pool, controller_id, group, name)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, pool, controller_id, group, name, group_type):
|
||||
self.pool = pool
|
||||
self.controller_id = controller_id
|
||||
self.group = group
|
||||
|
||||
self.pool.execute(self.controller_id, "set_group_type", self.group,
|
||||
group_type)
|
||||
|
||||
# LimitlessLEDs don't report state, we have track it ourselves.
|
||||
self.pool.execute(self.controller_id, "off", self.group)
|
||||
|
||||
self._name = name or DEVICE_DEFAULT_NAME
|
||||
self._state = False
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" No polling needed. """
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device if any. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if device is on. """
|
||||
return self._state
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turn the device off. """
|
||||
self._state = False
|
||||
self.pool.execute(self.controller_id, "off", self.group)
|
||||
self.update_ha_state()
|
||||
|
||||
|
||||
class RGBWLimitlessLED(LimitlessLED):
|
||||
""" Represents a RGBW LimitlessLED light """
|
||||
|
||||
def __init__(self, pool, controller_id, group, name):
|
||||
super().__init__(pool, controller_id, group, name, 'rgbw')
|
||||
|
||||
self._brightness = 100
|
||||
self._xy_color = color_RGB_to_xy(255, 255, 255)
|
||||
|
||||
# Build a color table that maps an RGB color to a color string
|
||||
# recognized by LedController's set_color method
|
||||
self._color_table = [(color_RGB_to_xy(*x[0]), x[1]) for x in [
|
||||
((0xFF, 0xFF, 0xFF), 'white'),
|
||||
((0xEE, 0x82, 0xEE), 'violet'),
|
||||
((0x41, 0x69, 0xE1), 'royal_blue'),
|
||||
((0x87, 0xCE, 0xFA), 'baby_blue'),
|
||||
((0x00, 0xFF, 0xFF), 'aqua'),
|
||||
((0x7F, 0xFF, 0xD4), 'royal_mint'),
|
||||
((0x2E, 0x8B, 0x57), 'seafoam_green'),
|
||||
((0x00, 0x80, 0x00), 'green'),
|
||||
((0x32, 0xCD, 0x32), 'lime_green'),
|
||||
((0xFF, 0xFF, 0x00), 'yellow'),
|
||||
((0xDA, 0xA5, 0x20), 'yellow_orange'),
|
||||
((0xFF, 0xA5, 0x00), 'orange'),
|
||||
((0xFF, 0x00, 0x00), 'red'),
|
||||
((0xFF, 0xC0, 0xCB), 'pink'),
|
||||
((0xFF, 0x00, 0xFF), 'fusia'),
|
||||
((0xDA, 0x70, 0xD6), 'lilac'),
|
||||
((0xE6, 0xE6, 0xFA), 'lavendar'),
|
||||
]]
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def color_xy(self):
|
||||
return self._xy_color
|
||||
|
||||
def _xy_to_led_color(self, xy_color):
|
||||
""" Convert an XY color to the closest LedController color string. """
|
||||
def abs_dist_squared(p_0, p_1):
|
||||
""" Returns the absolute value of the squared distance """
|
||||
return abs((p_0[0] - p_1[0])**2 + (p_0[1] - p_1[1])**2)
|
||||
|
||||
candidates = [(abs_dist_squared(xy_color, x[0]), x[1]) for x in
|
||||
self._color_table]
|
||||
|
||||
# First candidate in the sorted list is closest to desired color:
|
||||
return sorted(candidates)[0][1]
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turn the device on. """
|
||||
self._state = True
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
self._xy_color = kwargs[ATTR_XY_COLOR]
|
||||
|
||||
self.pool.execute(self.controller_id, "set_color",
|
||||
self._xy_to_led_color(self._xy_color), self.group)
|
||||
self.pool.execute(self.controller_id, "set_brightness",
|
||||
self._brightness / 255.0, self.group)
|
||||
self.update_ha_state()
|
||||
|
||||
|
||||
class WhiteLimitlessLED(LimitlessLED):
|
||||
""" Represents a White LimitlessLED light """
|
||||
|
||||
def __init__(self, pool, controller_id, group, name):
|
||||
super().__init__(pool, controller_id, group, name, 'white')
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turn the device on. """
|
||||
self._state = True
|
||||
self.pool.execute(self.controller_id, "on", self.group)
|
||||
self.update_ha_state()
|
||||
52
homeassistant/components/light/services.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
# Describes the format for available light services
|
||||
|
||||
turn_on:
|
||||
description: Turn a light on
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to turn on
|
||||
example: 'light.kitchen'
|
||||
|
||||
transition:
|
||||
description: Duration in seconds it takes to get to next state
|
||||
example: 60
|
||||
|
||||
rgb_color:
|
||||
description: Color for the light in RGB-format
|
||||
example: '[255, 100, 100]'
|
||||
|
||||
xy_color:
|
||||
description: Color for the light in XY-format
|
||||
example: '[0.52, 0.43]'
|
||||
|
||||
brightness:
|
||||
description: Number between 0..255 indicating brightness
|
||||
example: 120
|
||||
|
||||
profile:
|
||||
description: Name of a light profile to use
|
||||
example: relax
|
||||
|
||||
flash:
|
||||
description: If the light should flash
|
||||
values:
|
||||
- short
|
||||
- long
|
||||
|
||||
effect:
|
||||
description: Light effect
|
||||
values:
|
||||
- colorloop
|
||||
|
||||
turn_off:
|
||||
description: Turn a light off
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to turn off
|
||||
example: 'light.kitchen'
|
||||
|
||||
transition:
|
||||
description: Duration in seconds it takes to get to next state
|
||||
example: 60
|
||||