Compare commits
1487 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
bc4ab4d70a | ||
![]() |
901f63be0c | ||
![]() |
7ee71b1831 | ||
![]() |
70a5704887 | ||
![]() |
04316d9723 | ||
![]() |
14246fe467 | ||
![]() |
177590fd65 | ||
![]() |
1c08923ffc | ||
![]() |
7bb07cdc03 | ||
![]() |
90c705354a | ||
![]() |
55a236f912 | ||
![]() |
f071e3b4ac | ||
![]() |
a6a30c5f22 | ||
![]() |
dc8ac7386e | ||
![]() |
cc80ad00ff | ||
![]() |
824dd60aef | ||
![]() |
2efca59352 | ||
![]() |
4a0ec36fea | ||
![]() |
9bf8835190 | ||
![]() |
411ce04d75 | ||
![]() |
2e75f0b314 | ||
![]() |
dd60cc020e | ||
![]() |
9dca22aa27 | ||
![]() |
b5fc7f5e71 | ||
![]() |
80d3552960 | ||
![]() |
a0ff8819a9 | ||
![]() |
4196d6a21c | ||
![]() |
c564c73617 | ||
![]() |
5dabba3941 | ||
![]() |
257743facc | ||
![]() |
0369f5153e | ||
![]() |
04aa4ff3a8 | ||
![]() |
38c529502d | ||
![]() |
dbad01f73d | ||
![]() |
9087e6a0d0 | ||
![]() |
faff14ac94 | ||
![]() |
f2aeb2a6f9 | ||
![]() |
56b38e64ae | ||
![]() |
eb4f0e4ae9 | ||
![]() |
1747db1888 | ||
![]() |
5066e6a183 | ||
![]() |
8eb73c8f17 | ||
![]() |
3ce6463af3 | ||
![]() |
cceb79a378 | ||
![]() |
2a30c07429 | ||
![]() |
f246508c9a | ||
![]() |
eb6b4218fc | ||
![]() |
7fb5927ac8 | ||
![]() |
4eec89b35e | ||
![]() |
4c33eba378 | ||
![]() |
fe2ae16210 | ||
![]() |
ab8ff42cdd | ||
![]() |
687ce64551 | ||
![]() |
31b4c8eb4d | ||
![]() |
f6d16ba118 | ||
![]() |
69d8b995d4 | ||
![]() |
70bc6f4506 | ||
![]() |
afbf377b6d | ||
![]() |
da63800319 | ||
![]() |
0075752c06 | ||
![]() |
0a44750441 | ||
![]() |
c91cd9aa84 | ||
![]() |
c8234ffb8d | ||
![]() |
14056951c7 | ||
![]() |
e76defe035 | ||
![]() |
3b8c5d6833 | ||
![]() |
956a6418cc | ||
![]() |
507145e954 | ||
![]() |
40c2e10756 | ||
![]() |
58988787ea | ||
![]() |
79a1ffac9c | ||
![]() |
4f6243fe0e | ||
![]() |
2a3e086410 | ||
![]() |
0e9c51a479 | ||
![]() |
d6dad8c818 | ||
![]() |
17940f36e8 | ||
![]() |
149449624e | ||
![]() |
574de3b1b6 | ||
![]() |
85873390ee | ||
![]() |
4e6b01b0b9 | ||
![]() |
5ecdd06bd5 | ||
![]() |
9404636536 | ||
![]() |
a764817ee9 | ||
![]() |
dade33187f | ||
![]() |
b3c13747dc | ||
![]() |
a3f805d88e | ||
![]() |
66fca475c6 | ||
![]() |
9fa8b27d65 | ||
![]() |
455a9c83a6 | ||
![]() |
94eb002b0a | ||
![]() |
fcdaa923c5 | ||
![]() |
a35dcf860e | ||
![]() |
4029d149fb | ||
![]() |
027b891052 | ||
![]() |
2e0042adb0 | ||
![]() |
79c92cd0c4 | ||
![]() |
946f6cab9d | ||
![]() |
9e0d19df6c | ||
![]() |
6d38288411 | ||
![]() |
056645fe13 | ||
![]() |
d16526739c | ||
![]() |
6a4442cddf | ||
![]() |
c1d728ce00 | ||
![]() |
a517784c9e | ||
![]() |
91a945f4c7 | ||
![]() |
cec62bdf87 | ||
![]() |
53239387e0 | ||
![]() |
a1009d9138 | ||
![]() |
e821b546d5 | ||
![]() |
600831cff5 | ||
![]() |
87d40f6673 | ||
![]() |
5755c6f593 | ||
![]() |
3ed69cec68 | ||
![]() |
df24a1bfa7 | ||
![]() |
90f0632a69 | ||
![]() |
3faa1a4393 | ||
![]() |
f93d6a1a11 | ||
![]() |
0f777ecd4c | ||
![]() |
cc962c6bb2 | ||
![]() |
9833b4b663 | ||
![]() |
b3171c7cde | ||
![]() |
4528c57539 | ||
![]() |
7e9eaf14b8 | ||
![]() |
035d518cb6 | ||
![]() |
85b62a20c8 | ||
![]() |
46f742f82f | ||
![]() |
729c24d59b | ||
![]() |
f73f824e0e | ||
![]() |
360b99be59 | ||
![]() |
931f7e8615 | ||
![]() |
106a229a27 | ||
![]() |
649ce7befb | ||
![]() |
ed9b75756a | ||
![]() |
27c5c1cb9f | ||
![]() |
1d017beb93 | ||
![]() |
4286e116f3 | ||
![]() |
64e3db2444 | ||
![]() |
f458484770 | ||
![]() |
01c4038dec | ||
![]() |
27e3e72211 | ||
![]() |
a84ff14b00 | ||
![]() |
ab9ab532c8 | ||
![]() |
9fc0d93e44 | ||
![]() |
ded99d7480 | ||
![]() |
2b975c8620 | ||
![]() |
9eea7a6cde | ||
![]() |
b1bf6a609e | ||
![]() |
5c63862054 | ||
![]() |
0b325b2b7d | ||
![]() |
1c54111018 | ||
![]() |
13b0d2afa3 | ||
![]() |
a10908e167 | ||
![]() |
56bb9f2da0 | ||
![]() |
2368e5f4ec | ||
![]() |
84226b573e | ||
![]() |
e19af76c70 | ||
![]() |
9a9ecb5916 | ||
![]() |
d55fda28c2 | ||
![]() |
7acef84aad | ||
![]() |
5f8fc7e197 | ||
![]() |
0dcc69d800 | ||
![]() |
47b5fbfaf3 | ||
![]() |
d1383ac94d | ||
![]() |
af09a305cf | ||
![]() |
b440c260e6 | ||
![]() |
de68d3355a | ||
![]() |
fc8c26005c | ||
![]() |
452348a7f9 | ||
![]() |
d3a21bee82 | ||
![]() |
5ff6eb8b9c | ||
![]() |
5d3d2d4110 | ||
![]() |
3938b9f3f6 | ||
![]() |
48593c6a68 | ||
![]() |
d84bea3621 | ||
![]() |
9e41c495fc | ||
![]() |
c423a51315 | ||
![]() |
9f396b44f7 | ||
![]() |
ee339cfb20 | ||
![]() |
fe485cc27d | ||
![]() |
e1990e07c7 | ||
![]() |
0d4f681a4e | ||
![]() |
e60dce9712 | ||
![]() |
aff82da611 | ||
![]() |
2c157e6885 | ||
![]() |
aaca0a64cb | ||
![]() |
48c3bbd5ac | ||
![]() |
39e3a3c463 | ||
![]() |
fd9da7f9de | ||
![]() |
98467d0d9f | ||
![]() |
832674286b | ||
![]() |
9d8e077acc | ||
![]() |
b33e9fe6d9 | ||
![]() |
f4238ca242 | ||
![]() |
301194034e | ||
![]() |
b8de2c5ebf | ||
![]() |
527d30ae4a | ||
![]() |
535a3c399e | ||
![]() |
e0c4c8d7dd | ||
![]() |
d9b8ab8851 | ||
![]() |
b2ae365558 | ||
![]() |
1cb4d40bbb | ||
![]() |
b7712ac682 | ||
![]() |
a833e8b413 | ||
![]() |
e112620b2d | ||
![]() |
934b097bc7 | ||
![]() |
f8668075d0 | ||
![]() |
03962ab6ae | ||
![]() |
facbbabe58 | ||
![]() |
c4fe480b7b | ||
![]() |
705e3e4fbb | ||
![]() |
eefa62748b | ||
![]() |
be14499bca | ||
![]() |
f582137fe3 | ||
![]() |
254889e3fd | ||
![]() |
9ecc08c0c8 | ||
![]() |
e3d4e3ad4d | ||
![]() |
b6342ed848 | ||
![]() |
0383dddae7 | ||
![]() |
d0ae22428d | ||
![]() |
107994f3ac | ||
![]() |
853aa2ac42 | ||
![]() |
7985468aba | ||
![]() |
502184812d | ||
![]() |
02a8ce71d0 | ||
![]() |
08de7d954a | ||
![]() |
9d8865ad4d | ||
![]() |
c3f0d618be | ||
![]() |
21ee621aec | ||
![]() |
dd0424435a | ||
![]() |
0555294afe | ||
![]() |
28bbf39155 | ||
![]() |
587ef04560 | ||
![]() |
38f28fe24b | ||
![]() |
1ce81ee6db | ||
![]() |
228142e497 | ||
![]() |
9bd11205fb | ||
![]() |
0025e67b05 | ||
![]() |
5e2641e288 | ||
![]() |
f173f8b88b | ||
![]() |
d6cac08be6 | ||
![]() |
2e89f0a8c7 | ||
![]() |
758c0aae24 | ||
![]() |
8fee38f2cd | ||
![]() |
41ab635dcd | ||
![]() |
48a424e86f | ||
![]() |
6113988ccd | ||
![]() |
2732b20305 | ||
![]() |
ac59847120 | ||
![]() |
b53993e8c0 | ||
![]() |
90eab17ea6 | ||
![]() |
5b4f607da1 | ||
![]() |
72ebb22eba | ||
![]() |
dc2f3861c9 | ||
![]() |
6a25af4a9f | ||
![]() |
6e0c30d9f7 | ||
![]() |
022d6af6fc | ||
![]() |
32003daf3f | ||
![]() |
5fba67f6c3 | ||
![]() |
a3981be501 | ||
![]() |
343b5bd2b8 | ||
![]() |
35f1810c74 | ||
![]() |
7ba9fb90f1 | ||
![]() |
5023772b3e | ||
![]() |
ee805ec145 | ||
![]() |
a301d869d7 | ||
![]() |
cb0eb2df7d | ||
![]() |
18ca7b4f9e | ||
![]() |
aff1c27372 | ||
![]() |
01203c7c4c | ||
![]() |
8841eef2b7 | ||
![]() |
52b39efc51 | ||
![]() |
7d503e3f8b | ||
![]() |
73e3ce5044 | ||
![]() |
f4c3fbe8fd | ||
![]() |
41a0f2c198 | ||
![]() |
0016ff6acc | ||
![]() |
45bd371cbf | ||
![]() |
70698f7ab0 | ||
![]() |
286299c4c9 | ||
![]() |
f76edf0ed9 | ||
![]() |
733de6b357 | ||
![]() |
e67732b4f8 | ||
![]() |
546377e80a | ||
![]() |
0df39b4df5 | ||
![]() |
64ebe8c6d0 | ||
![]() |
78cfed1fb0 | ||
![]() |
99aa4307ef | ||
![]() |
ef394b8af7 | ||
![]() |
aebab47fff | ||
![]() |
f2d553bfd1 | ||
![]() |
e84ef2d2d7 | ||
![]() |
957b09707d | ||
![]() |
9ecfc41e09 | ||
![]() |
194e7f8811 | ||
![]() |
6a021c9ef6 | ||
![]() |
b6d7cacc61 | ||
![]() |
e6fdcc94e6 | ||
![]() |
05978ad88d | ||
![]() |
1146d6e58d | ||
![]() |
6809a881fa | ||
![]() |
9f01d7abca | ||
![]() |
bbfeba0fe4 | ||
![]() |
ad3f96fa25 | ||
![]() |
d91fe792c5 | ||
![]() |
93c86ca5a1 | ||
![]() |
bbf73e0bae | ||
![]() |
47eb4e76cf | ||
![]() |
ada612e09d | ||
![]() |
00f0dfb971 | ||
![]() |
7204bc018f | ||
![]() |
7224775aa8 | ||
![]() |
60460e8217 | ||
![]() |
67bcb00c9e | ||
![]() |
74b37bd61b | ||
![]() |
08da1e6f8d | ||
![]() |
fabd0ced3f | ||
![]() |
2861bbb02c | ||
![]() |
4c3d6981a6 | ||
![]() |
341c3a8fcd | ||
![]() |
69e9d39690 | ||
![]() |
65f3e7ccf4 | ||
![]() |
067011af15 | ||
![]() |
271c2f6537 | ||
![]() |
f2f598bd26 | ||
![]() |
4f75286f64 | ||
![]() |
c4d4dcccb7 | ||
![]() |
128e3bb762 | ||
![]() |
a33220db7f | ||
![]() |
4bd0db30c9 | ||
![]() |
e60ab8f4c2 | ||
![]() |
38b564e413 | ||
![]() |
76ac913fc0 | ||
![]() |
0dbf4b3c10 | ||
![]() |
08ba71a359 | ||
![]() |
1bebb17e9f | ||
![]() |
e45ef0013b | ||
![]() |
9dfa4ea233 | ||
![]() |
a8ddae9343 | ||
![]() |
3e60c4801c | ||
![]() |
bc106bcb10 | ||
![]() |
6f3cebdacf | ||
![]() |
b0fa80ad4c | ||
![]() |
067b5862c0 | ||
![]() |
351430c1b3 | ||
![]() |
e001ea913a | ||
![]() |
901b9075dc | ||
![]() |
d141306493 | ||
![]() |
6c12580b69 | ||
![]() |
0d3a099926 | ||
![]() |
09a82dedf0 | ||
![]() |
80e829f53a | ||
![]() |
27bc4c582b | ||
![]() |
cc196d9888 | ||
![]() |
68ff9dd74f | ||
![]() |
d4b3af327d | ||
![]() |
8269e843f2 | ||
![]() |
7acb3dffe4 | ||
![]() |
1bda0bd73b | ||
![]() |
f170799182 | ||
![]() |
d4f0f0ffd3 | ||
![]() |
7f1254d750 | ||
![]() |
100400f149 | ||
![]() |
90681c2dc9 | ||
![]() |
dbcd055cfe | ||
![]() |
d6feb82f9b | ||
![]() |
fc0e76764d | ||
![]() |
64c9619d75 | ||
![]() |
8074854731 | ||
![]() |
fff6b24449 | ||
![]() |
396a65ab03 | ||
![]() |
ee73bc9ad4 | ||
![]() |
8dc0de1d05 | ||
![]() |
715abf241e | ||
![]() |
f37d0d1c20 | ||
![]() |
0621260435 | ||
![]() |
427944cc44 | ||
![]() |
422a93e735 | ||
![]() |
b4ee3f73b4 | ||
![]() |
44abc31057 | ||
![]() |
820b2a31b3 | ||
![]() |
08d29d3630 | ||
![]() |
44714614ad | ||
![]() |
065f4b7c20 | ||
![]() |
030686a978 | ||
![]() |
799043dc0a | ||
![]() |
105dc2847e | ||
![]() |
b4cf0e874a | ||
![]() |
3df6c584c0 | ||
![]() |
3493db1052 | ||
![]() |
d254e7e9e5 | ||
![]() |
52344d65bc | ||
![]() |
a6006b1835 | ||
![]() |
b9730e6914 | ||
![]() |
037bca041e | ||
![]() |
77a1a1529c | ||
![]() |
fa7391cdf6 | ||
![]() |
d25f58e650 | ||
![]() |
6d315a1a7c | ||
![]() |
685964785d | ||
![]() |
ca32c81612 | ||
![]() |
5f89db5957 | ||
![]() |
4eacea32da | ||
![]() |
d05af62680 | ||
![]() |
f1fed78992 | ||
![]() |
8df32aac3c | ||
![]() |
18d0f4461f | ||
![]() |
c6d1a4bdaf | ||
![]() |
ab9e173179 | ||
![]() |
22fcbc67cf | ||
![]() |
e317e0798b | ||
![]() |
c78899c4f3 | ||
![]() |
bca65b620a | ||
![]() |
e92fe149fe | ||
![]() |
377d2c6e5a | ||
![]() |
8be53af78f | ||
![]() |
573dfb648f | ||
![]() |
8e44ed0090 | ||
![]() |
5fb6076f6e | ||
![]() |
4ea6def1bd | ||
![]() |
314185ffb8 | ||
![]() |
8aafb89a64 | ||
![]() |
d01ed9788f | ||
![]() |
b4e2f4f0be | ||
![]() |
f4d8325084 | ||
![]() |
cc5dec3c59 | ||
![]() |
ec5a93a0fd | ||
![]() |
aabda1b7b0 | ||
![]() |
aee4411cfb | ||
![]() |
135eb0a0ac | ||
![]() |
01daac066a | ||
![]() |
5d96ca133d | ||
![]() |
3cda1aacff | ||
![]() |
243130c133 | ||
![]() |
0d74b628b0 | ||
![]() |
c314101dde | ||
![]() |
0f68dc6b7b | ||
![]() |
919c20a263 | ||
![]() |
9b5385c565 | ||
![]() |
7dacf01baa | ||
![]() |
9b4650afd4 | ||
![]() |
0b0fd2490d | ||
![]() |
3a85bebbf6 | ||
![]() |
18f1de10a5 | ||
![]() |
869d6df65e | ||
![]() |
0a586bd919 | ||
![]() |
a98b1b0ebc | ||
![]() |
511028612c | ||
![]() |
e3efce5ded | ||
![]() |
340ee171b5 | ||
![]() |
773da3f755 | ||
![]() |
12bdc39274 | ||
![]() |
700b7ba591 | ||
![]() |
295f27d259 | ||
![]() |
4463b69245 | ||
![]() |
71e4283a2e | ||
![]() |
6135b87b1d | ||
![]() |
04bb7ed58f | ||
![]() |
dfa9880176 | ||
![]() |
88f3a5a50a | ||
![]() |
e2c530b85d | ||
![]() |
56c5d345a4 | ||
![]() |
86b9ae9566 | ||
![]() |
df264f2ec0 | ||
![]() |
aabcad59d8 | ||
![]() |
70fef3c5b5 | ||
![]() |
57ec58e255 | ||
![]() |
cf8e23adbc | ||
![]() |
eabf9087f3 | ||
![]() |
9acb341b96 | ||
![]() |
5275ca9ce7 | ||
![]() |
646618a25e | ||
![]() |
bc48e4f98e | ||
![]() |
776324807e | ||
![]() |
d68a4b52f1 | ||
![]() |
85e0db6ade | ||
![]() |
d993f4014e | ||
![]() |
7ebda9c3c6 | ||
![]() |
16e948d032 | ||
![]() |
41d0f95d9a | ||
![]() |
bfaaf39e9e | ||
![]() |
c60bb35d4a | ||
![]() |
90007a04d3 | ||
![]() |
16904452b8 | ||
![]() |
158d9e27ff | ||
![]() |
b652dd47cd | ||
![]() |
2812fae721 | ||
![]() |
6da88108fe | ||
![]() |
5503c12cfd | ||
![]() |
329d63ac11 | ||
![]() |
698e30bd2b | ||
![]() |
90063ea7f8 | ||
![]() |
0c52b143ae | ||
![]() |
8f12b997f8 | ||
![]() |
877926cfee | ||
![]() |
19649390d3 | ||
![]() |
50d19bb1b4 | ||
![]() |
665436cd91 | ||
![]() |
1a3410119e | ||
![]() |
bf2bcb6dcf | ||
![]() |
8371b08676 | ||
![]() |
e8a0d54fdd | ||
![]() |
f4a82c6f6b | ||
![]() |
963c4bb70e | ||
![]() |
ec2e0cc77d | ||
![]() |
0c0ccb361d | ||
![]() |
994fc32f25 | ||
![]() |
d68263d5c4 | ||
![]() |
27b001df2b | ||
![]() |
dafc0ced6b | ||
![]() |
3ec2555c66 | ||
![]() |
bfa8e58879 | ||
![]() |
2d9a785c18 | ||
![]() |
97f9f8aa49 | ||
![]() |
64d5ca4da0 | ||
![]() |
4fb301b7a9 | ||
![]() |
fda65a4934 | ||
![]() |
98b4c27211 | ||
![]() |
3b3f5fe6fe | ||
![]() |
1c9c5ce1bd | ||
![]() |
a36b315927 | ||
![]() |
f3352546c6 | ||
![]() |
3a6aa8f3d1 | ||
![]() |
dfa81b0117 | ||
![]() |
3947691347 | ||
![]() |
8541fdb112 | ||
![]() |
e078ab53ca | ||
![]() |
0665af7f0f | ||
![]() |
6dfb8f5737 | ||
![]() |
1be2be0886 | ||
![]() |
3a095f53a8 | ||
![]() |
ffce252a12 | ||
![]() |
7f4c13c382 | ||
![]() |
0f292e8fa6 | ||
![]() |
e63d0c51e0 | ||
![]() |
ebaecdb9d6 | ||
![]() |
fd50693ca7 | ||
![]() |
ebc95aca51 | ||
![]() |
137cadb59c | ||
![]() |
84f81480bb | ||
![]() |
5565e418f8 | ||
![]() |
5b4fc4f346 | ||
![]() |
e4c3d47dbf | ||
![]() |
95320f39b3 | ||
![]() |
0c97280479 | ||
![]() |
3e339c7304 | ||
![]() |
6f06f48ac6 | ||
![]() |
aeacbad4a0 | ||
![]() |
f60f3fa4a2 | ||
![]() |
e49dc94d4b | ||
![]() |
26a6438e93 | ||
![]() |
cae71a73a1 | ||
![]() |
df7f6e1235 | ||
![]() |
a31f7d2816 | ||
![]() |
c52c982510 | ||
![]() |
ea06d946e6 | ||
![]() |
bd798b8c55 | ||
![]() |
ac7456b73b | ||
![]() |
d69b08ecf5 | ||
![]() |
be6dd20236 | ||
![]() |
4d069323f4 | ||
![]() |
77f4fc8c22 | ||
![]() |
72b4212b19 | ||
![]() |
5fce381b89 | ||
![]() |
7b968f6a6b | ||
![]() |
4d958c6d18 | ||
![]() |
566712023d | ||
![]() |
3cd89f8474 | ||
![]() |
218a05356a | ||
![]() |
186f68cce3 | ||
![]() |
168eb8e5a2 | ||
![]() |
c6b5a04312 | ||
![]() |
728cd8bb5e | ||
![]() |
d873ab0262 | ||
![]() |
0ff6a460c2 | ||
![]() |
77539a5b89 | ||
![]() |
92b05389f2 | ||
![]() |
82aec895a0 | ||
![]() |
ec732becfc | ||
![]() |
c4261ae2e0 | ||
![]() |
cae8932b18 | ||
![]() |
efacd66bec | ||
![]() |
31826ab263 | ||
![]() |
6bb95f5c9b | ||
![]() |
46761d827f | ||
![]() |
288db9a57f | ||
![]() |
b76471c4b3 | ||
![]() |
194c6343ac | ||
![]() |
2ad647bb09 | ||
![]() |
a56173676e | ||
![]() |
e961dd5f95 | ||
![]() |
0269be5813 | ||
![]() |
031d5ce255 | ||
![]() |
85bb828149 | ||
![]() |
c7a0b5800c | ||
![]() |
f456d2ff23 | ||
![]() |
6bad702db4 | ||
![]() |
bcb2451752 | ||
![]() |
8eeca94517 | ||
![]() |
3b37a7b737 | ||
![]() |
d578bf3494 | ||
![]() |
8cb046a4a9 | ||
![]() |
30de5af445 | ||
![]() |
f74a6ed4c9 | ||
![]() |
f3500542dd | ||
![]() |
2eb65c8eb8 | ||
![]() |
f8bb807707 | ||
![]() |
5f40115605 | ||
![]() |
776616bcac | ||
![]() |
a98cb798f7 | ||
![]() |
12495c717e | ||
![]() |
10c95b4352 | ||
![]() |
48bfc98acb | ||
![]() |
de027609d8 | ||
![]() |
16a3511c0a | ||
![]() |
0a36c96a55 | ||
![]() |
6ef0d089ea | ||
![]() |
b3b2f2e326 | ||
![]() |
bef0b2b01e | ||
![]() |
805aecd6f9 | ||
![]() |
e4d33bc6d4 | ||
![]() |
e25503bc4a | ||
![]() |
4fcd27e905 | ||
![]() |
52b1080ccd | ||
![]() |
0128357024 | ||
![]() |
c5f8095f53 | ||
![]() |
ab48a94d2a | ||
![]() |
27c6c27db6 | ||
![]() |
55c0ee6b32 | ||
![]() |
6b881ce1cd | ||
![]() |
9ab9d0e383 | ||
![]() |
dbc05450a0 | ||
![]() |
b66e4f1e15 | ||
![]() |
649124d162 | ||
![]() |
49f4d92c62 | ||
![]() |
fbb73dd5da | ||
![]() |
c9f1dce6a2 | ||
![]() |
ef6c209c6f | ||
![]() |
0826ae2742 | ||
![]() |
18747f8ae1 | ||
![]() |
06c8c1b168 | ||
![]() |
160fb6fcc8 | ||
![]() |
f1aa685cf2 | ||
![]() |
da259d75a2 | ||
![]() |
9104ca815d | ||
![]() |
52193611cd | ||
![]() |
a2256f6c97 | ||
![]() |
004bad7f00 | ||
![]() |
a8c2cc4c33 | ||
![]() |
538f8545f7 | ||
![]() |
7cfce94dfb | ||
![]() |
e490388843 | ||
![]() |
fb8edca942 | ||
![]() |
469d0619ba | ||
![]() |
bc8c5766d4 | ||
![]() |
5b25d9ccd6 | ||
![]() |
0d05930765 | ||
![]() |
f93282d636 | ||
![]() |
78ad2686d4 | ||
![]() |
3c36d13e8d | ||
![]() |
77ba0c0393 | ||
![]() |
6a3316ed12 | ||
![]() |
415d650860 | ||
![]() |
c3c248bc0a | ||
![]() |
bf027fcd48 | ||
![]() |
5c79fc0ae3 | ||
![]() |
0aaf280bf5 | ||
![]() |
55de563511 | ||
![]() |
1a018e3ee7 | ||
![]() |
b023348795 | ||
![]() |
847d9736aa | ||
![]() |
884525df33 | ||
![]() |
6a82504e5e | ||
![]() |
db7e46abd1 | ||
![]() |
8e9cafd29d | ||
![]() |
a236b87ccf | ||
![]() |
e379e3d902 | ||
![]() |
1be48f54c0 | ||
![]() |
96181a555a | ||
![]() |
e461ceae36 | ||
![]() |
83e6c24f18 | ||
![]() |
50fbd83b3d | ||
![]() |
649275044a | ||
![]() |
0f81fc60ad | ||
![]() |
ade8681511 | ||
![]() |
7a699fd819 | ||
![]() |
b0c0659acc | ||
![]() |
80a9422a9a | ||
![]() |
060cbaf66b | ||
![]() |
c19120e012 | ||
![]() |
1e0e48fcd7 | ||
![]() |
f2fda2914a | ||
![]() |
7e3483ab03 | ||
![]() |
bffce11a9a | ||
![]() |
8a895390ef | ||
![]() |
97f81ad7a6 | ||
![]() |
67d5581a1b | ||
![]() |
3406b41b0c | ||
![]() |
2e7912157b | ||
![]() |
5dbdf82ec7 | ||
![]() |
9f4a3f4aea | ||
![]() |
4f3b3a9e34 | ||
![]() |
3ea167203f | ||
![]() |
756cbe1b08 | ||
![]() |
87e55820e7 | ||
![]() |
f828ee044d | ||
![]() |
a8e2f9cbb7 | ||
![]() |
2e3f462474 | ||
![]() |
3f6780d9be | ||
![]() |
e21921823e | ||
![]() |
f9b2e0058e | ||
![]() |
a155587693 | ||
![]() |
0e145ec130 | ||
![]() |
44b08a06e7 | ||
![]() |
75f737144a | ||
![]() |
170742b0a7 | ||
![]() |
6115be7c42 | ||
![]() |
84a9a300d6 | ||
![]() |
55718aac66 | ||
![]() |
b6e6512367 | ||
![]() |
29c9c5a7ec | ||
![]() |
dd787ea5ce | ||
![]() |
c2d75efb4d | ||
![]() |
3d972abdab | ||
![]() |
7801489149 | ||
![]() |
0fda89e983 | ||
![]() |
07a75c5eeb | ||
![]() |
0b7c407519 | ||
![]() |
3c34f3dac2 | ||
![]() |
d45074f9dc | ||
![]() |
863955e1bd | ||
![]() |
4ff1b0fdb2 | ||
![]() |
ba13f13442 | ||
![]() |
e615755eb9 | ||
![]() |
352d3532e7 | ||
![]() |
7b60f6ca77 | ||
![]() |
a2e8fcbc77 | ||
![]() |
e10fd0d28b | ||
![]() |
f376061e23 | ||
![]() |
076b3db5e8 | ||
![]() |
89964ad793 | ||
![]() |
bddd02bd58 | ||
![]() |
cfb3384ee3 | ||
![]() |
490e9ee95d | ||
![]() |
0d0eb7e7c0 | ||
![]() |
3d4af8c229 | ||
![]() |
7e23c241da | ||
![]() |
7ec1424825 | ||
![]() |
9162149598 | ||
![]() |
da31b54d06 | ||
![]() |
b20a757454 | ||
![]() |
761f225c86 | ||
![]() |
5580309d98 | ||
![]() |
52b4c3b5a2 | ||
![]() |
aed61cecff | ||
![]() |
ef129639bd | ||
![]() |
f945a3a692 | ||
![]() |
5e56eae28f | ||
![]() |
72ad1387f0 | ||
![]() |
02cfc70ad5 | ||
![]() |
5a21b677a1 | ||
![]() |
c1a73d250a | ||
![]() |
8c544a89c9 | ||
![]() |
c473d426e3 | ||
![]() |
f5a62f8381 | ||
![]() |
0e8e4a73fe | ||
![]() |
74700e4b10 | ||
![]() |
4d5c9581bf | ||
![]() |
f45e0eabe3 | ||
![]() |
bbed4a262c | ||
![]() |
f8590f7d1d | ||
![]() |
293ed275ab | ||
![]() |
2e9ee28637 | ||
![]() |
73cb23f599 | ||
![]() |
661f4c594e | ||
![]() |
9464e2a13b | ||
![]() |
27d5248937 | ||
![]() |
c5a7e3abd1 | ||
![]() |
0a6424a81d | ||
![]() |
5b7389de55 | ||
![]() |
0fe4e0330a | ||
![]() |
40b095b866 | ||
![]() |
7141a99927 | ||
![]() |
f01d2b1263 | ||
![]() |
3c6420c538 | ||
![]() |
e3304caf06 | ||
![]() |
91a1fb0240 | ||
![]() |
6fd32e83c8 | ||
![]() |
b41caa093c | ||
![]() |
806c71c803 | ||
![]() |
b0bafa32b7 | ||
![]() |
332ac794a4 | ||
![]() |
5dfd0d2502 | ||
![]() |
3d838c307f | ||
![]() |
2dd77f9477 | ||
![]() |
6c106a87f1 | ||
![]() |
1279bf7c68 | ||
![]() |
323d301072 | ||
![]() |
b1815075ac | ||
![]() |
7ba4263284 | ||
![]() |
347597ebdc | ||
![]() |
ddeb84cb9c | ||
![]() |
b2e39884f9 | ||
![]() |
8d99c4a0cc | ||
![]() |
80e4f2f51f | ||
![]() |
6064fffc8e | ||
![]() |
7da354c4c5 | ||
![]() |
49de153ecf | ||
![]() |
716376081d | ||
![]() |
d37b70556d | ||
![]() |
32bb950b5f | ||
![]() |
0e89418cbe | ||
![]() |
fe032be352 | ||
![]() |
64a78d7b4f | ||
![]() |
24e4b9e012 | ||
![]() |
a44a39003d | ||
![]() |
f019b4f697 | ||
![]() |
e353dae3a6 | ||
![]() |
fb84c0ce6b | ||
![]() |
fd382871a1 | ||
![]() |
405025a00b | ||
![]() |
912ddbb4fc | ||
![]() |
91138b8679 | ||
![]() |
893b9fc8ac | ||
![]() |
185ba000dd | ||
![]() |
771118caaf | ||
![]() |
17d9df0da5 | ||
![]() |
e067398134 | ||
![]() |
cb69ac30ec | ||
![]() |
241ff45c5e | ||
![]() |
c5c2f0c5f3 | ||
![]() |
0874cb364f | ||
![]() |
44418b509c | ||
![]() |
403889bbeb | ||
![]() |
6ca50d8b5c | ||
![]() |
0369a9ee0d | ||
![]() |
5a6ff9a69a | ||
![]() |
796cce78bc | ||
![]() |
77430c0687 | ||
![]() |
c33942d6e2 | ||
![]() |
3b91f89173 | ||
![]() |
c74f46794e | ||
![]() |
966fd8f24d | ||
![]() |
8253fdfc13 | ||
![]() |
cb7b5f8d15 | ||
![]() |
b0d8eaeda9 | ||
![]() |
62cfb8aeb2 | ||
![]() |
47448d1dc0 | ||
![]() |
a583525110 | ||
![]() |
38e1cef30e | ||
![]() |
e2f187879c | ||
![]() |
925cde200f | ||
![]() |
383efee470 | ||
![]() |
d5eb90160f | ||
![]() |
2f946cc6de | ||
![]() |
1b7ce2146c | ||
![]() |
021a374a6a | ||
![]() |
bbec34d0e6 | ||
![]() |
a6cb19b27d | ||
![]() |
b74e70d4e0 | ||
![]() |
5cd283e999 | ||
![]() |
2f2bd7a616 | ||
![]() |
cddc87b0ab | ||
![]() |
d6bbc67112 | ||
![]() |
6d77b15e44 | ||
![]() |
916c453d2b | ||
![]() |
7786b52d93 | ||
![]() |
90d4a2c0b8 | ||
![]() |
b6d26597c0 | ||
![]() |
bf1970b78c | ||
![]() |
1eb3610a11 | ||
![]() |
f081f7c4ff | ||
![]() |
6a969208e9 | ||
![]() |
384b3d0d17 | ||
![]() |
ad5b650661 | ||
![]() |
b05f2e3221 | ||
![]() |
dcfc91e71c | ||
![]() |
6c1c243000 | ||
![]() |
c2117b3eaf | ||
![]() |
84c72ebf63 | ||
![]() |
a1e5bea3ab | ||
![]() |
bee5c0adfb | ||
![]() |
7ca21f577d | ||
![]() |
168516f5da | ||
![]() |
94df5acbf3 | ||
![]() |
853a9fd4cd | ||
![]() |
6a18205d2e | ||
![]() |
3a3b8bbb45 | ||
![]() |
37278aab20 | ||
![]() |
d3c4722529 | ||
![]() |
c3de67041a | ||
![]() |
f07d07432d | ||
![]() |
47f994b867 | ||
![]() |
a8a172c8b7 | ||
![]() |
d4d91bfdbb | ||
![]() |
7432bbd70c | ||
![]() |
a3d295d885 | ||
![]() |
0cf909cce9 | ||
![]() |
fc1cf49fd3 | ||
![]() |
e5d68d8a1e | ||
![]() |
8fc2f5fe36 | ||
![]() |
c2c18bdbd5 | ||
![]() |
f8efe3f00f | ||
![]() |
db53e46705 | ||
![]() |
526a163563 | ||
![]() |
e29f857f43 | ||
![]() |
9f6ce868e2 | ||
![]() |
3ef5e7c161 | ||
![]() |
fe5bb89a68 | ||
![]() |
be8089bcde | ||
![]() |
47fc1deecb | ||
![]() |
8a04e1f5f4 | ||
![]() |
9f33b8f541 | ||
![]() |
0624725e21 | ||
![]() |
dc5f0ef314 | ||
![]() |
cb2943c247 | ||
![]() |
45f0911640 | ||
![]() |
4ac9e9fc4c | ||
![]() |
28b107ffa9 | ||
![]() |
e0149c4ee4 | ||
![]() |
455a5916fd | ||
![]() |
cbf94aae55 | ||
![]() |
39ced09727 | ||
![]() |
ad417bfdfb | ||
![]() |
f682fd7c1f | ||
![]() |
ee23c0fe14 | ||
![]() |
5322789c14 | ||
![]() |
05cec772d0 | ||
![]() |
75c3e42064 | ||
![]() |
61c955779b | ||
![]() |
a015df7b01 | ||
![]() |
721c1d0f54 | ||
![]() |
fe37a6aecc | ||
![]() |
85bf6cb568 | ||
![]() |
9f10ab5e7a | ||
![]() |
3b7f6d3b67 | ||
![]() |
d8aefb5d55 | ||
![]() |
06cac7f9ef | ||
![]() |
6d3f18d094 | ||
![]() |
050f90d07a | ||
![]() |
bf9b179441 | ||
![]() |
4f0f7eff5e | ||
![]() |
4edbdab4c0 | ||
![]() |
729f59625e | ||
![]() |
213a1fe4ba | ||
![]() |
c1899609a4 | ||
![]() |
1b4ef3856a | ||
![]() |
4673a82c90 | ||
![]() |
ae6f651c7d | ||
![]() |
7cb0f805ee | ||
![]() |
d556e5979a | ||
![]() |
d149f9d64c | ||
![]() |
9377b647f5 | ||
![]() |
469f35d25f | ||
![]() |
17865c78c4 | ||
![]() |
a5dae78155 | ||
![]() |
46f5ef54a1 | ||
![]() |
496e4cf784 | ||
![]() |
11fc521e60 | ||
![]() |
a58382e763 | ||
![]() |
9d4aa7e519 | ||
![]() |
ffbaf0cd5a | ||
![]() |
3b58e8628d | ||
![]() |
c2fe977778 | ||
![]() |
85338887b4 | ||
![]() |
9a3c76c263 | ||
![]() |
6ab4b80486 | ||
![]() |
5e0a4c316f | ||
![]() |
26939ce554 | ||
![]() |
c83324d4cf | ||
![]() |
dbcc3a76ea | ||
![]() |
faa3e98921 | ||
![]() |
1c4ac6017d | ||
![]() |
25a690691b | ||
![]() |
bb997deb85 | ||
![]() |
6c4b2fd638 | ||
![]() |
c4f8017a3f | ||
![]() |
6afb846d04 | ||
![]() |
ad549be353 | ||
![]() |
9012ba53fd | ||
![]() |
bdb42bf4a2 | ||
![]() |
1b22f71a19 | ||
![]() |
e84ddb036f | ||
![]() |
4be33bb15b | ||
![]() |
d17174d43d | ||
![]() |
e64846e2fd | ||
![]() |
d454cad5a6 | ||
![]() |
dcf52332ca | ||
![]() |
87599df41b | ||
![]() |
e535f50e03 | ||
![]() |
df7fbf664e | ||
![]() |
0fb9e1b16c | ||
![]() |
047cff6596 | ||
![]() |
32f1791c5a | ||
![]() |
3b49d1e876 | ||
![]() |
4d1dce2519 | ||
![]() |
01d097b9b0 | ||
![]() |
6d53944fa1 | ||
![]() |
7f60f1e662 | ||
![]() |
bc6c285945 | ||
![]() |
d4d8c9ae65 | ||
![]() |
9292891836 | ||
![]() |
035df68d6c | ||
![]() |
c611be96ad | ||
![]() |
d46720ee2c | ||
![]() |
c1f464f478 | ||
![]() |
8c5759e460 | ||
![]() |
8490d6126a | ||
![]() |
7f71706f08 | ||
![]() |
8b5b580287 | ||
![]() |
d35f5b9f97 | ||
![]() |
6de64d7695 | ||
![]() |
7f1da8b7bc | ||
![]() |
58ac4be24c | ||
![]() |
b2919c6504 | ||
![]() |
db509ccf18 | ||
![]() |
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 | ||
![]() |
cc47e39006 | ||
![]() |
2eb36c18bd | ||
![]() |
d64f0ddd41 | ||
![]() |
5ad27d8cdb | ||
![]() |
68c2b539ee | ||
![]() |
e57b3ae847 | ||
![]() |
755234369d | ||
![]() |
0a34e8de02 | ||
![]() |
52ed25fc21 | ||
![]() |
9e866680d4 | ||
![]() |
80c89d218b | ||
![]() |
4f1bf7b2bf | ||
![]() |
e557e355db | ||
![]() |
174aeacd76 | ||
![]() |
e7320fe969 | ||
![]() |
321a603bfe | ||
![]() |
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 | ||
![]() |
1553844279 | ||
![]() |
826b3be087 | ||
![]() |
f5000d401b | ||
![]() |
7443f4faf8 | ||
![]() |
582ed1fc8d | ||
![]() |
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 |
47
.coveragerc
@@ -4,8 +4,6 @@ source = homeassistant
|
||||
omit =
|
||||
homeassistant/__main__.py
|
||||
|
||||
homeassistant/external/*
|
||||
|
||||
# omit pieces of code that rely on external devices being present
|
||||
homeassistant/components/arduino.py
|
||||
homeassistant/components/*/arduino.py
|
||||
@@ -19,6 +17,9 @@ omit =
|
||||
homeassistant/components/*/tellstick.py
|
||||
homeassistant/components/*/vera.py
|
||||
|
||||
homeassistant/components/ecobee.py
|
||||
homeassistant/components/*/ecobee.py
|
||||
|
||||
homeassistant/components/verisure.py
|
||||
homeassistant/components/*/verisure.py
|
||||
|
||||
@@ -28,57 +29,93 @@ omit =
|
||||
homeassistant/components/zwave.py
|
||||
homeassistant/components/*/zwave.py
|
||||
|
||||
homeassistant/components/rfxtrx.py
|
||||
homeassistant/components/*/rfxtrx.py
|
||||
|
||||
homeassistant/components/binary_sensor/arest.py
|
||||
homeassistant/components/binary_sensor/rest.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/fritz.py
|
||||
homeassistant/components/device_tracker/geofancy.py
|
||||
homeassistant/components/device_tracker/icloud.py
|
||||
homeassistant/components/device_tracker/luci.py
|
||||
homeassistant/components/device_tracker/netgear.py
|
||||
homeassistant/components/device_tracker/nmap_tracker.py
|
||||
homeassistant/components/device_tracker/owntracks.py
|
||||
homeassistant/components/device_tracker/snmp.py
|
||||
homeassistant/components/device_tracker/thomson.py
|
||||
homeassistant/components/device_tracker/tomato.py
|
||||
homeassistant/components/device_tracker/tplink.py
|
||||
homeassistant/components/device_tracker/ubus.py
|
||||
homeassistant/components/discovery.py
|
||||
homeassistant/components/downloader.py
|
||||
homeassistant/components/ifttt.py
|
||||
homeassistant/components/influxdb.py
|
||||
homeassistant/components/keyboard.py
|
||||
homeassistant/components/light/blinksticklight.py
|
||||
homeassistant/components/light/hue.py
|
||||
homeassistant/components/light/hyperion.py
|
||||
homeassistant/components/light/limitlessled.py
|
||||
homeassistant/components/media_player/cast.py
|
||||
homeassistant/components/media_player/denon.py
|
||||
homeassistant/components/media_player/firetv.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/sonos.py
|
||||
homeassistant/components/media_player/squeezebox.py
|
||||
homeassistant/components/notify/file.py
|
||||
homeassistant/components/notify/instapush.py
|
||||
homeassistant/components/notify/nma.py
|
||||
homeassistant/components/notify/pushbullet.py
|
||||
homeassistant/components/notify/pushetta.py
|
||||
homeassistant/components/notify/pushover.py
|
||||
homeassistant/components/notify/slack.py
|
||||
homeassistant/components/notify/smtp.py
|
||||
homeassistant/components/notify/syslog.py
|
||||
homeassistant/components/notify/telegram.py
|
||||
homeassistant/components/notify/xmpp.py
|
||||
homeassistant/components/sensor/arest.py
|
||||
homeassistant/components/sensor/bitcoin.py
|
||||
homeassistant/components/sensor/cpuspeed.py
|
||||
homeassistant/components/sensor/dht.py
|
||||
homeassistant/components/sensor/dweet.py
|
||||
homeassistant/components/sensor/efergy.py
|
||||
homeassistant/components/sensor/eliqonline.py
|
||||
homeassistant/components/sensor/forecast.py
|
||||
homeassistant/components/sensor/glances.py
|
||||
homeassistant/components/sensor/mysensors.py
|
||||
homeassistant/components/sensor/openweathermap.py
|
||||
homeassistant/components/sensor/rfxtrx.py
|
||||
homeassistant/components/sensor/rest.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/torque.py
|
||||
homeassistant/components/sensor/transmission.py
|
||||
homeassistant/components/switch/command_switch.py
|
||||
homeassistant/components/sensor/twitch.py
|
||||
homeassistant/components/sensor/worldclock.py
|
||||
homeassistant/components/switch/arest.py
|
||||
homeassistant/components/switch/edimax.py
|
||||
homeassistant/components/switch/hikvisioncam.py
|
||||
homeassistant/components/switch/mystrom.py
|
||||
homeassistant/components/switch/orvibo.py
|
||||
homeassistant/components/switch/rest.py
|
||||
homeassistant/components/switch/rpi_gpio.py
|
||||
homeassistant/components/switch/transmission.py
|
||||
homeassistant/components/switch/wemo.py
|
||||
homeassistant/components/thermostat/heatmiser.py
|
||||
homeassistant/components/thermostat/homematic.py
|
||||
homeassistant/components/thermostat/honeywell.py
|
||||
homeassistant/components/thermostat/nest.py
|
||||
homeassistant/components/thermostat/radiotherm.py
|
||||
|
||||
|
||||
[report]
|
||||
|
7
.gitignore
vendored
@@ -15,10 +15,6 @@ tests/config/home-assistant.log
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Hide code validator output
|
||||
pep8.txt
|
||||
pylint.txt
|
||||
|
||||
# Hide some OS X stuff
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
@@ -30,6 +26,9 @@ Icon
|
||||
|
||||
.idea
|
||||
|
||||
# pytest
|
||||
.cache
|
||||
|
||||
# GITHUB Proposed Python stuff:
|
||||
*.py[cod]
|
||||
|
||||
|
9
.gitmodules
vendored
@@ -1,12 +1,3 @@
|
||||
[submodule "homeassistant/external/noop"]
|
||||
path = homeassistant/external/noop
|
||||
url = https://github.com/balloob/noop.git
|
||||
[submodule "homeassistant/external/vera"]
|
||||
path = homeassistant/external/vera
|
||||
url = https://github.com/jamespcole/home-assistant-vera-api.git
|
||||
[submodule "homeassistant/external/nzbclients"]
|
||||
path = homeassistant/external/nzbclients
|
||||
url = https://github.com/jamespcole/home-assistant-nzb-clients.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
|
||||
|
20
.travis.yml
@@ -1,13 +1,17 @@
|
||||
sudo: false
|
||||
language: python
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
# - "$HOME/virtualenv/python$TRAVIS_PYTHON_VERSION"
|
||||
python:
|
||||
- "3.4"
|
||||
- 3.4
|
||||
- 3.5
|
||||
install:
|
||||
- pip install -r requirements_all.txt
|
||||
- pip install flake8 pylint coveralls
|
||||
# Validate requirements_all.txt on Python 3.5
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == '3.5' ]]; then python3 setup.py develop; script/gen_requirements_all.py validate; fi
|
||||
- script/bootstrap_server
|
||||
script:
|
||||
- flake8 homeassistant --exclude bower_components,external
|
||||
- pylint homeassistant
|
||||
- coverage run -m unittest discover tests
|
||||
after_success:
|
||||
- coveralls
|
||||
- script/cibuild
|
||||
matrix:
|
||||
fast_finish: true
|
||||
|
@@ -17,19 +17,18 @@ For help on building your component, please see the [developer documentation](ht
|
||||
|
||||
After you finish adding support for your device:
|
||||
|
||||
- Update the supported devices in the `README.md` file.
|
||||
- Add any new dependencies to `requirements.txt`.
|
||||
- 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`.
|
||||
- Add any new dependencies to `requirements_all.txt` if needed. Use `script/gen_requirements_all.py`.
|
||||
- Update the `.coveragerc` file to exclude your platform if there are no tests available.
|
||||
- Provide some documentation for [home-assistant.io](https://home-assistant.io/). It's OK to just add a docstring with configuration details (sample entry for `configuration.yaml` file and alike) to the file header as a start. Visit the [website documentation](https://home-assistant.io/developers/website/) for further information on contributing to [home-assistant.io](https://github.com/balloob/home-assistant.io).
|
||||
- Make sure all your code passes ``pylint`` and ``flake8`` (PEP8 and some more) validation. To check your repository, run `./script/lint`.
|
||||
- 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:
|
||||
If you add a platform for an existing component, there is usually no need for updating the frontend. Only if you've added a new component that should show up in the frontend, there are more steps needed:
|
||||
|
||||
- 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
|
||||
- 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:
|
||||
|
||||
|
23
Dockerfile
@@ -1,20 +1,27 @@
|
||||
FROM python:3-onbuild
|
||||
FROM python:3.4
|
||||
MAINTAINER Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>
|
||||
|
||||
VOLUME /config
|
||||
|
||||
RUN pip3 install --no-cache-dir -r requirements_all.txt
|
||||
RUN mkdir -p /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# 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
|
||||
COPY script/build_python_openzwave script/build_python_openzwave
|
||||
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<0.23" && \
|
||||
script/build_python_openzwave
|
||||
|
||||
COPY requirements_all.txt requirements_all.txt
|
||||
RUN pip3 install --no-cache-dir -r requirements_all.txt
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
CMD [ "python", "-m", "homeassistant", "--config", "/config" ]
|
||||
|
5
MANIFEST.in
Normal file
@@ -0,0 +1,5 @@
|
||||
include README.rst
|
||||
include LICENSE
|
||||
graft homeassistant
|
||||
prune homeassistant/components/frontend/www_static/home-assistant-polymer
|
||||
recursive-exclude * *.py[co]
|
37
README.md
@@ -1,37 +0,0 @@
|
||||
# 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)
|
||||
|
||||
[demo]: https://home-assistant.io/demo/
|
||||
|
||||
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.
|
||||
|
||||
To get started:
|
||||
```bash
|
||||
python3 -m pip install homeassistant
|
||||
hass --open-ui
|
||||
```
|
||||
|
||||
Check out [the website](https://home-assistant.io) for [a demo][demo], installation instructions, tutorials and documentation.
|
||||
|
||||
[][demo]
|
||||
|
||||
Examples of devices it can interface it:
|
||||
|
||||
* 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), and [Kodi (XBMC)](http://kodi.tv/)
|
||||
* 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/)
|
||||
|
||||
Built home automation on top of your devices:
|
||||
|
||||
* 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)
|
||||
|
||||
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).
|
||||
|
||||
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.
|
98
README.rst
Normal file
@@ -0,0 +1,98 @@
|
||||
Home Assistant |Build Status| |Coverage Status| |Join the chat at https://gitter.im/balloob/home-assistant|
|
||||
===========================================================================================================
|
||||
|
||||
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.
|
||||
|
||||
To get started:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
python3 -m pip install homeassistant
|
||||
hass --open-ui
|
||||
|
||||
Check out `the website <https://home-assistant.io>`__ for `a
|
||||
demo <https://home-assistant.io/demo/>`__, installation instructions,
|
||||
tutorials and documentation.
|
||||
|
||||
|screenshot-states|
|
||||
|
||||
Examples of devices it can interface it:
|
||||
|
||||
- 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/>`__,
|
||||
`ASUSWRT <http://event.asus.com/2013/nw/ASUSWRT/>`__ and any SNMP
|
||||
capable Linksys WAP/WRT
|
||||
- `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, 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>`__,
|
||||
`Plex <https://plex.tv/>`__, `Kodi (XBMC) <http://kodi.tv/>`__,
|
||||
iTunes (by way of
|
||||
`itunes-api <https://github.com/maddox/itunes-api>`__), and Amazon
|
||||
Fire TV (by way of
|
||||
`python-firetv <https://github.com/happyleavesaoc/python-firetv>`__)
|
||||
- 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/>`__,
|
||||
`RFXtrx <http://www.rfxcom.com/>`__,
|
||||
`Arduino <https://www.arduino.cc/>`__, `Raspberry
|
||||
Pi <https://www.raspberrypi.org/>`__, and
|
||||
`Modbus <http://www.modbus.org/>`__
|
||||
- Interaction with `IFTTT <https://ifttt.com/>`__
|
||||
- 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/>`__
|
||||
|
||||
Built home automation on top of your devices:
|
||||
|
||||
- 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/>`__
|
||||
and can interface with MQTT for easy integration with other projects
|
||||
like `OwnTracks <http://owntracks.org/>`__
|
||||
- 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/>`__,
|
||||
`Telegram <https://telegram.org/>`__, and `Jabber
|
||||
(XMPP) <http://xmpp.org>`__
|
||||
|
||||
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>`__.
|
||||
|
||||
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.
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/balloob/home-assistant.svg?branch=master
|
||||
:target: https://travis-ci.org/balloob/home-assistant
|
||||
.. |Coverage Status| image:: https://img.shields.io/coveralls/balloob/home-assistant.svg
|
||||
:target: https://coveralls.io/r/balloob/home-assistant?branch=master
|
||||
.. |Join the chat at https://gitter.im/balloob/home-assistant| image:: https://badges.gitter.im/Join%20Chat.svg
|
||||
:target: https://gitter.im/balloob/home-assistant?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
|
||||
.. |screenshot-states| image:: https://raw.github.com/balloob/home-assistant/master/docs/screenshots.png
|
||||
:target: https://home-assistant.io/demo/
|
@@ -1,7 +1,9 @@
|
||||
homeassistant:
|
||||
# Omitted values in this section will be auto detected using freegeoip.net
|
||||
|
||||
# Location required to calculate the time the sun rises and sets
|
||||
# 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
|
||||
|
||||
@@ -68,11 +70,18 @@ device_sun_light_trigger:
|
||||
|
||||
# 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
|
||||
@@ -94,28 +103,39 @@ browser:
|
||||
keyboard:
|
||||
|
||||
automation:
|
||||
platform: state
|
||||
alias: Sun starts shining
|
||||
- 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
|
||||
|
||||
state_entity_id: sun.sun
|
||||
# Next two are optional, omit to match all
|
||||
state_from: below_horizon
|
||||
state_to: above_horizon
|
||||
- alias: 'Rule 2 - Away Mode'
|
||||
|
||||
execute_service: light.turn_off
|
||||
service_entity_id: group.living_room
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: group.all_devices
|
||||
state: 'not_home'
|
||||
|
||||
automation 2:
|
||||
platform: time
|
||||
alias: Beer o Clock
|
||||
condition: use_trigger_values
|
||||
action:
|
||||
service: light.turn_off
|
||||
entity_id: group.all_lights
|
||||
|
||||
time_hours: 16
|
||||
time_minutes: 0
|
||||
time_seconds: 0
|
||||
|
||||
execute_service: notify.notify
|
||||
service_data:
|
||||
message: It's 4, time for beer!
|
||||
# Sensors need to be added into the configuration.yaml as sensor:, sensor 2:, sensor 3:, etc.
|
||||
# Each sensor label should be unique or your sensors might not load correctly.
|
||||
|
||||
sensor:
|
||||
platform: systemmonitor
|
||||
@@ -135,6 +155,23 @@ sensor:
|
||||
- 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:
|
||||
|
@@ -12,7 +12,7 @@ Example component to target an entity_id to:
|
||||
Configuration:
|
||||
|
||||
To use the Example custom component you will need to add the following to
|
||||
your config/configuration.yaml
|
||||
your configuration.yaml file.
|
||||
|
||||
example:
|
||||
target: TARGET_ENTITY
|
||||
|
@@ -1,16 +1,14 @@
|
||||
"""
|
||||
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
|
||||
config/configuration.yaml
|
||||
configuration.yaml file.
|
||||
|
||||
hello_world:
|
||||
|
||||
"""
|
||||
|
||||
# The domain of your component. Should be equal to the name of your component
|
||||
|
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -12,7 +11,7 @@ example payload {"new_state": "some new state"}.
|
||||
Configuration:
|
||||
|
||||
To use the mqtt_example component you will need to add the following to your
|
||||
config/configuration.yaml
|
||||
configuration.yaml file.
|
||||
|
||||
mqtt_example:
|
||||
topic: home-assistant/mqtt_example
|
||||
|
@@ -77,6 +77,10 @@ def get_arguments():
|
||||
'--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',
|
||||
@@ -86,6 +90,23 @@ def get_arguments():
|
||||
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',
|
||||
@@ -143,6 +164,46 @@ def write_pid(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()
|
||||
@@ -152,6 +213,18 @@ def main():
|
||||
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)
|
||||
@@ -161,15 +234,20 @@ def main():
|
||||
write_pid(args.pid_file)
|
||||
|
||||
if args.demo_mode:
|
||||
hass = bootstrap.from_config_dict({
|
||||
config = {
|
||||
'frontend': {},
|
||||
'demo': {}
|
||||
}, config_dir=config_dir, daemon=args.daemon, verbose=args.verbose)
|
||||
}
|
||||
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)
|
||||
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):
|
||||
|
@@ -9,10 +9,12 @@ After bootstrapping you can add your own components or
|
||||
start by calling homeassistant.start_home_assistant(bus)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import homeassistant.core as core
|
||||
import homeassistant.util.dt as date_util
|
||||
@@ -24,7 +26,7 @@ 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,
|
||||
__version__, EVENT_COMPONENT_LOADED, CONF_LATITUDE, CONF_LONGITUDE,
|
||||
CONF_TEMPERATURE_UNIT, CONF_NAME, CONF_TIME_ZONE, CONF_CUSTOMIZE,
|
||||
TEMP_CELCIUS, TEMP_FAHRENHEIT)
|
||||
|
||||
@@ -33,6 +35,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
ATTR_COMPONENT = 'component'
|
||||
|
||||
PLATFORM_FORMAT = '{}.{}'
|
||||
ERROR_LOG_FILENAME = 'home-assistant.log'
|
||||
|
||||
|
||||
def setup_component(hass, domain, config=None):
|
||||
@@ -61,7 +64,7 @@ def setup_component(hass, domain, config=None):
|
||||
|
||||
def _handle_requirements(hass, component, name):
|
||||
""" Installs requirements for component. """
|
||||
if not hasattr(component, 'REQUIREMENTS'):
|
||||
if hass.config.skip_pip or not hasattr(component, 'REQUIREMENTS'):
|
||||
return True
|
||||
|
||||
for req in component.REQUIREMENTS:
|
||||
@@ -79,7 +82,7 @@ def _setup_component(hass, domain, config):
|
||||
return True
|
||||
component = loader.get_component(domain)
|
||||
|
||||
missing_deps = [dep for dep in component.DEPENDENCIES
|
||||
missing_deps = [dep for dep in getattr(component, 'DEPENDENCIES', [])
|
||||
if dep not in hass.config.components]
|
||||
|
||||
if missing_deps:
|
||||
@@ -103,7 +106,7 @@ def _setup_component(hass, domain, config):
|
||||
|
||||
# Assumption: if a component does not depend on groups
|
||||
# it communicates with devices
|
||||
if group.DOMAIN not in component.DEPENDENCIES:
|
||||
if group.DOMAIN not in getattr(component, 'DEPENDENCIES', []):
|
||||
hass.pool.add_worker()
|
||||
|
||||
hass.bus.fire(
|
||||
@@ -122,6 +125,7 @@ def prepare_setup_platform(hass, config, domain, platform_name):
|
||||
|
||||
# Not found
|
||||
if platform is None:
|
||||
_LOGGER.error('Unable to find platform %s', platform_path)
|
||||
return None
|
||||
|
||||
# Already loaded
|
||||
@@ -129,14 +133,13 @@ def prepare_setup_platform(hass, config, domain, platform_name):
|
||||
return platform
|
||||
|
||||
# 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
|
||||
for component in getattr(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
|
||||
|
||||
if not _handle_requirements(hass, platform, platform_path):
|
||||
return None
|
||||
@@ -151,7 +154,8 @@ def mount_local_lib_path(config_dir):
|
||||
|
||||
# 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):
|
||||
verbose=False, daemon=False, skip_pip=False,
|
||||
log_rotate_days=None):
|
||||
"""
|
||||
Tries to configure Home Assistant from a config dict.
|
||||
|
||||
@@ -164,10 +168,16 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
|
||||
hass.config.config_dir = config_dir
|
||||
mount_local_lib_path(config_dir)
|
||||
|
||||
process_ha_config_upgrade(hass)
|
||||
process_ha_core_config(hass, config.get(core.DOMAIN, {}))
|
||||
|
||||
if enable_log:
|
||||
enable_logging(hass, verbose, daemon)
|
||||
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)
|
||||
|
||||
@@ -178,8 +188,8 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
|
||||
dict, {key: value or {} for key, value in config.items()})
|
||||
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
components = (key for key in config.keys()
|
||||
if ' ' not in key and key != core.DOMAIN)
|
||||
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. '
|
||||
@@ -196,7 +206,8 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
|
||||
return hass
|
||||
|
||||
|
||||
def from_config_file(config_path, hass=None, verbose=False, daemon=False):
|
||||
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,
|
||||
@@ -210,14 +221,15 @@ def from_config_file(config_path, hass=None, verbose=False, daemon=False):
|
||||
hass.config.config_dir = config_dir
|
||||
mount_local_lib_path(config_dir)
|
||||
|
||||
enable_logging(hass, verbose, daemon)
|
||||
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)
|
||||
return from_config_dict(config_dict, hass, enable_log=False,
|
||||
skip_pip=skip_pip)
|
||||
|
||||
|
||||
def enable_logging(hass, verbose=False, daemon=False):
|
||||
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)
|
||||
@@ -242,7 +254,7 @@ def enable_logging(hass, verbose=False, daemon=False):
|
||||
"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_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
err_path_exists = os.path.isfile(err_log_path)
|
||||
|
||||
# Check if we can write to the error log if it exists or that
|
||||
@@ -250,8 +262,12 @@ def enable_logging(hass, verbose=False, daemon=False):
|
||||
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)):
|
||||
|
||||
err_handler = logging.FileHandler(
|
||||
err_log_path, mode='w', delay=True)
|
||||
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(
|
||||
@@ -259,13 +275,38 @@ def enable_logging(hass, verbose=False, daemon=False):
|
||||
datefmt='%y-%m-%d %H:%M:%S'))
|
||||
logger = logging.getLogger('')
|
||||
logger.addHandler(err_handler)
|
||||
logger.setLevel(logging.INFO) # this sets the minimum log level
|
||||
logger.setLevel(logging.NOTSET) # this sets the minimum log level
|
||||
|
||||
else:
|
||||
_LOGGER.error(
|
||||
'Unable to setup error log %s (access denied)', err_log_path)
|
||||
|
||||
|
||||
def process_ha_config_upgrade(hass):
|
||||
""" Upgrade config if necessary. """
|
||||
version_path = hass.config.path('.HA_VERSION')
|
||||
|
||||
try:
|
||||
with open(version_path, 'rt') as inp:
|
||||
conf_version = inp.readline().strip()
|
||||
except FileNotFoundError:
|
||||
# Last version to not have this file
|
||||
conf_version = '0.7.7'
|
||||
|
||||
if conf_version == __version__:
|
||||
return
|
||||
|
||||
_LOGGER.info('Upgrading config directory from %s to %s', conf_version,
|
||||
__version__)
|
||||
|
||||
lib_path = hass.config.path('lib')
|
||||
if os.path.isdir(lib_path):
|
||||
shutil.rmtree(lib_path)
|
||||
|
||||
with open(version_path, 'wt') as outp:
|
||||
outp.write(__version__)
|
||||
|
||||
|
||||
def process_ha_core_config(hass, config):
|
||||
""" Processes the [homeassistant] section from the config. """
|
||||
hac = hass.config
|
||||
@@ -283,11 +324,15 @@ def process_ha_core_config(hass, config):
|
||||
else:
|
||||
_LOGGER.error('Received invalid time zone %s', time_zone_str)
|
||||
|
||||
for key, attr in ((CONF_LATITUDE, 'latitude'),
|
||||
(CONF_LONGITUDE, 'longitude'),
|
||||
(CONF_NAME, 'location_name')):
|
||||
for key, attr, typ in ((CONF_LATITUDE, 'latitude', float),
|
||||
(CONF_LONGITUDE, 'longitude', float),
|
||||
(CONF_NAME, 'location_name', str)):
|
||||
if key in config:
|
||||
setattr(hac, attr, config[key])
|
||||
try:
|
||||
setattr(hac, attr, typ(config[key]))
|
||||
except ValueError:
|
||||
_LOGGER.error('Received invalid %s value for %s: %s',
|
||||
typ.__name__, key, attr)
|
||||
|
||||
set_time_zone(config.get(CONF_TIME_ZONE))
|
||||
|
||||
|
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
homeassistant.components
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This package contains components that can be plugged into Home Assistant.
|
||||
|
||||
Component design guidelines:
|
||||
@@ -12,7 +11,6 @@ Each component that tracks states should create state entity names in the
|
||||
format "<DOMAIN>.<OBJECT_ID>".
|
||||
|
||||
Each component should publish services only under its own domain.
|
||||
|
||||
"""
|
||||
import itertools as it
|
||||
import logging
|
||||
|
150
homeassistant/components/alarm_control_panel/__init__.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
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_TRIGGER,
|
||||
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'
|
||||
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',
|
||||
SERVICE_ALARM_TRIGGER: 'alarm_trigger'
|
||||
}
|
||||
|
||||
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:
|
||||
code = None
|
||||
else:
|
||||
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=None, entity_id=None):
|
||||
""" Send the alarm the command for disarm. """
|
||||
data = {}
|
||||
if code:
|
||||
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=None, entity_id=None):
|
||||
""" Send the alarm the command for arm home. """
|
||||
data = {}
|
||||
if code:
|
||||
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=None, entity_id=None):
|
||||
""" Send the alarm the command for arm away. """
|
||||
data = {}
|
||||
if code:
|
||||
data[ATTR_CODE] = code
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data)
|
||||
|
||||
|
||||
def alarm_trigger(hass, code=None, entity_id=None):
|
||||
""" Send the alarm the command for trigger. """
|
||||
data = {}
|
||||
if code:
|
||||
data[ATTR_CODE] = code
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_ALARM_TRIGGER, 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()
|
||||
|
||||
def alarm_trigger(self, code=None):
|
||||
""" Send alarm trigger command. """
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Return the state attributes. """
|
||||
state_attr = {
|
||||
ATTR_CODE_FORMAT: self.code_format,
|
||||
}
|
||||
return state_attr
|
13
homeassistant/components/alarm_control_panel/demo.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
homeassistant.components.alarm_control_panel.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Demo platform that has two fake alarm control panels.
|
||||
"""
|
||||
import homeassistant.components.alarm_control_panel.manual as manual
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the Demo alarm control panels. """
|
||||
add_devices([
|
||||
manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10),
|
||||
])
|
147
homeassistant/components/alarm_control_panel/manual.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
homeassistant.components.alarm_control_panel.manual
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for manual alarms.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.manual/
|
||||
"""
|
||||
import logging
|
||||
import datetime
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.helpers.event import track_point_in_time
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ALARM_NAME = 'HA Alarm'
|
||||
DEFAULT_PENDING_TIME = 60
|
||||
DEFAULT_TRIGGER_TIME = 120
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the manual alarm platform. """
|
||||
|
||||
add_devices([ManualAlarm(
|
||||
hass,
|
||||
config.get('name', DEFAULT_ALARM_NAME),
|
||||
config.get('code'),
|
||||
config.get('pending_time', DEFAULT_PENDING_TIME),
|
||||
config.get('trigger_time', DEFAULT_TRIGGER_TIME),
|
||||
)])
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-instance-attributes
|
||||
# pylint: disable=abstract-method
|
||||
class ManualAlarm(alarm.AlarmControlPanel):
|
||||
"""
|
||||
Represents an alarm status.
|
||||
|
||||
When armed, will be pending for 'pending_time', after that armed.
|
||||
When triggered, will be pending for 'trigger_time'. After that will be
|
||||
triggered for 'trigger_time', after that we return to disarmed.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, name, code, pending_time, trigger_time):
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
self._hass = hass
|
||||
self._name = name
|
||||
self._code = str(code) if code else None
|
||||
self._pending_time = datetime.timedelta(seconds=pending_time)
|
||||
self._trigger_time = datetime.timedelta(seconds=trigger_time)
|
||||
self._state_ts = None
|
||||
|
||||
@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. """
|
||||
if self._state in (STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY) and \
|
||||
self._pending_time and self._state_ts + self._pending_time > \
|
||||
dt_util.utcnow():
|
||||
return STATE_ALARM_PENDING
|
||||
|
||||
if self._state == STATE_ALARM_TRIGGERED and self._trigger_time:
|
||||
if self._state_ts + self._pending_time > dt_util.utcnow():
|
||||
return STATE_ALARM_PENDING
|
||||
elif (self._state_ts + self._pending_time +
|
||||
self._trigger_time) < dt_util.utcnow():
|
||||
return STATE_ALARM_DISARMED
|
||||
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
""" One or more characters. """
|
||||
return None if self._code is None else '.+'
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
""" Send disarm command. """
|
||||
if not self._validate_code(code, STATE_ALARM_DISARMED):
|
||||
return
|
||||
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.update_ha_state()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
""" Send arm home command. """
|
||||
if not self._validate_code(code, STATE_ALARM_ARMED_HOME):
|
||||
return
|
||||
|
||||
self._state = STATE_ALARM_ARMED_HOME
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.update_ha_state()
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
""" Send arm away command. """
|
||||
if not self._validate_code(code, STATE_ALARM_ARMED_AWAY):
|
||||
return
|
||||
|
||||
self._state = STATE_ALARM_ARMED_AWAY
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.update_ha_state()
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
|
||||
def alarm_trigger(self, code=None):
|
||||
""" Send alarm trigger command. No code needed. """
|
||||
self._state = STATE_ALARM_TRIGGERED
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.update_ha_state()
|
||||
|
||||
if self._trigger_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
|
||||
track_point_in_time(
|
||||
self._hass, self.update_ha_state,
|
||||
self._state_ts + self._pending_time + self._trigger_time)
|
||||
|
||||
def _validate_code(self, code, state):
|
||||
""" Validate given code. """
|
||||
check = self._code is None or code == self._code
|
||||
if not check:
|
||||
_LOGGER.warning('Invalid code given for %s', state)
|
||||
return check
|
127
homeassistant/components/alarm_control_panel/mqtt.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
homeassistant.components.alarm_control_panel.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
This platform enables the possibility to control a MQTT alarm.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.mqtt/
|
||||
"""
|
||||
import logging
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, 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
|
||||
# pylint: disable=abstract-method
|
||||
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 = str(code) if code else None
|
||||
|
||||
def message_received(topic, payload, qos):
|
||||
""" A new MQTT message has been received. """
|
||||
if payload not in (STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_PENDING,
|
||||
STATE_ALARM_TRIGGERED):
|
||||
_LOGGER.warning('Received unexpected payload: %s', payload)
|
||||
return
|
||||
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 not self._validate_code(code, 'disarming'):
|
||||
return
|
||||
mqtt.publish(self.hass, self._command_topic,
|
||||
self._payload_disarm, self._qos)
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
""" Send arm home command. """
|
||||
if not self._validate_code(code, 'arming home'):
|
||||
return
|
||||
mqtt.publish(self.hass, self._command_topic,
|
||||
self._payload_arm_home, self._qos)
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
""" Send arm away command. """
|
||||
if not self._validate_code(code, 'arming away'):
|
||||
return
|
||||
mqtt.publish(self.hass, self._command_topic,
|
||||
self._payload_arm_away, self._qos)
|
||||
|
||||
def _validate_code(self, code, state):
|
||||
""" Validate given code. """
|
||||
check = self._code is None or code == self._code
|
||||
if not check:
|
||||
_LOGGER.warning('Wrong code entered for %s', state)
|
||||
return check
|
97
homeassistant/components/alarm_control_panel/verisure.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
homeassistant.components.alarm_control_panel.verisure
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Interfaces with Verisure alarm control panel.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/verisure/
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
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')
|
186
homeassistant/components/alexa.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
components.alexa
|
||||
~~~~~~~~~~~~~~~~
|
||||
Component to offer a service end point for an Alexa skill.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/alexa/
|
||||
"""
|
||||
import enum
|
||||
import logging
|
||||
|
||||
from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY
|
||||
from homeassistant.util import template
|
||||
|
||||
DOMAIN = 'alexa'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_CONFIG = {}
|
||||
|
||||
API_ENDPOINT = '/api/alexa'
|
||||
|
||||
CONF_INTENTS = 'intents'
|
||||
CONF_CARD = 'card'
|
||||
CONF_SPEECH = 'speech'
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Activate Alexa component. """
|
||||
_CONFIG.update(config[DOMAIN].get(CONF_INTENTS, {}))
|
||||
|
||||
hass.http.register_path('POST', API_ENDPOINT, _handle_alexa, True)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_alexa(handler, path_match, data):
|
||||
""" Handle Alexa. """
|
||||
_LOGGER.debug('Received Alexa request: %s', data)
|
||||
|
||||
req = data.get('request')
|
||||
|
||||
if req is None:
|
||||
_LOGGER.error('Received invalid data from Alexa: %s', data)
|
||||
handler.write_json_message(
|
||||
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
req_type = req['type']
|
||||
|
||||
if req_type == 'SessionEndedRequest':
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.end_headers()
|
||||
return
|
||||
|
||||
intent = req.get('intent')
|
||||
response = AlexaResponse(handler.server.hass, intent)
|
||||
|
||||
if req_type == 'LaunchRequest':
|
||||
response.add_speech(
|
||||
SpeechType.plaintext,
|
||||
"Hello, and welcome to the future. How may I help?")
|
||||
handler.write_json(response.as_dict())
|
||||
return
|
||||
|
||||
if req_type != 'IntentRequest':
|
||||
_LOGGER.warning('Received unsupported request: %s', req_type)
|
||||
return
|
||||
|
||||
intent_name = intent['name']
|
||||
config = _CONFIG.get(intent_name)
|
||||
|
||||
if config is None:
|
||||
_LOGGER.warning('Received unknown intent %s', intent_name)
|
||||
response.add_speech(
|
||||
SpeechType.plaintext,
|
||||
"This intent is not yet configured within Home Assistant.")
|
||||
handler.write_json(response.as_dict())
|
||||
return
|
||||
|
||||
speech = config.get(CONF_SPEECH)
|
||||
card = config.get(CONF_CARD)
|
||||
|
||||
# pylint: disable=unsubscriptable-object
|
||||
if speech is not None:
|
||||
response.add_speech(SpeechType[speech['type']], speech['text'])
|
||||
|
||||
if card is not None:
|
||||
response.add_card(CardType[card['type']], card['title'],
|
||||
card['content'])
|
||||
|
||||
handler.write_json(response.as_dict())
|
||||
|
||||
|
||||
class SpeechType(enum.Enum):
|
||||
""" Alexa speech types. """
|
||||
plaintext = "PlainText"
|
||||
ssml = "SSML"
|
||||
|
||||
|
||||
class CardType(enum.Enum):
|
||||
""" Alexa card types. """
|
||||
simple = "Simple"
|
||||
link_account = "LinkAccount"
|
||||
|
||||
|
||||
class AlexaResponse(object):
|
||||
""" Helps generating the response for Alexa. """
|
||||
|
||||
def __init__(self, hass, intent=None):
|
||||
self.hass = hass
|
||||
self.speech = None
|
||||
self.card = None
|
||||
self.reprompt = None
|
||||
self.session_attributes = {}
|
||||
self.should_end_session = True
|
||||
if intent is not None and 'slots' in intent:
|
||||
self.variables = {key: value['value'] for key, value
|
||||
in intent['slots'].items()}
|
||||
else:
|
||||
self.variables = {}
|
||||
|
||||
def add_card(self, card_type, title, content):
|
||||
""" Add a card to the response. """
|
||||
assert self.card is None
|
||||
|
||||
card = {
|
||||
"type": card_type.value
|
||||
}
|
||||
|
||||
if card_type == CardType.link_account:
|
||||
self.card = card
|
||||
return
|
||||
|
||||
card["title"] = self._render(title),
|
||||
card["content"] = self._render(content)
|
||||
self.card = card
|
||||
|
||||
def add_speech(self, speech_type, text):
|
||||
""" Add speech to the response. """
|
||||
assert self.speech is None
|
||||
|
||||
key = 'ssml' if speech_type == SpeechType.ssml else 'text'
|
||||
|
||||
self.speech = {
|
||||
'type': speech_type.value,
|
||||
key: self._render(text)
|
||||
}
|
||||
|
||||
def add_reprompt(self, speech_type, text):
|
||||
""" Add repromopt if user does not answer. """
|
||||
assert self.reprompt is None
|
||||
|
||||
key = 'ssml' if speech_type == SpeechType.ssml else 'text'
|
||||
|
||||
self.reprompt = {
|
||||
'type': speech_type.value,
|
||||
key: self._render(text)
|
||||
}
|
||||
|
||||
def as_dict(self):
|
||||
""" Returns response in an Alexa valid dict. """
|
||||
response = {
|
||||
'shouldEndSession': self.should_end_session
|
||||
}
|
||||
|
||||
if self.card is not None:
|
||||
response['card'] = self.card
|
||||
|
||||
if self.speech is not None:
|
||||
response['outputSpeech'] = self.speech
|
||||
|
||||
if self.reprompt is not None:
|
||||
response['reprompt'] = {
|
||||
'outputSpeech': self.reprompt
|
||||
}
|
||||
|
||||
return {
|
||||
'version': '1.0',
|
||||
'sessionAttributes': self.session_attributes,
|
||||
'response': response,
|
||||
}
|
||||
|
||||
def _render(self, template_string):
|
||||
""" Render a response, adding data from intent if available. """
|
||||
return template.render(self.hass, template_string, self.variables)
|
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
homeassistant.components.api
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides a Rest API for Home Assistant.
|
||||
|
||||
For more details about the RESTful API, please refer to the documentation at
|
||||
https://home-assistant.io/developers/api/
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
@@ -10,15 +12,19 @@ import threading
|
||||
import json
|
||||
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.state import TrackStates
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.util import template
|
||||
from homeassistant.bootstrap import ERROR_LOG_FILENAME
|
||||
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,
|
||||
URL_API_CONFIG, URL_API_BOOTSTRAP, URL_API_ERROR_LOG, URL_API_LOG_OUT,
|
||||
URL_API_TEMPLATE, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL,
|
||||
HTTP_OK, HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
HTTP_UNPROCESSABLE_ENTITY, HTTP_HEADER_CONTENT_TYPE,
|
||||
CONTENT_TYPE_TEXT_PLAIN)
|
||||
|
||||
|
||||
DOMAIN = 'api'
|
||||
@@ -33,10 +39,6 @@ _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)
|
||||
|
||||
@@ -87,6 +89,14 @@ def setup(hass, config):
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_COMPONENTS, _handle_get_api_components)
|
||||
|
||||
hass.http.register_path('GET', URL_API_ERROR_LOG,
|
||||
_handle_get_api_error_log)
|
||||
|
||||
hass.http.register_path('POST', URL_API_LOG_OUT, _handle_post_api_log_out)
|
||||
|
||||
hass.http.register_path('POST', URL_API_TEMPLATE,
|
||||
_handle_post_api_template)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -102,6 +112,11 @@ def _handle_get_api_stream(handler, path_match, data):
|
||||
wfile = handler.wfile
|
||||
write_lock = threading.Lock()
|
||||
block = threading.Event()
|
||||
session_id = None
|
||||
|
||||
restrict = data.get('restrict')
|
||||
if restrict:
|
||||
restrict = restrict.split(',')
|
||||
|
||||
def write_message(payload):
|
||||
""" Writes a message to the output. """
|
||||
@@ -111,7 +126,9 @@ def _handle_get_api_stream(handler, path_match, data):
|
||||
try:
|
||||
wfile.write(msg.encode("UTF-8"))
|
||||
wfile.flush()
|
||||
except IOError:
|
||||
except (IOError, ValueError):
|
||||
# IOError: socket errors
|
||||
# ValueError: raised when 'I/O operation on closed file'
|
||||
block.set()
|
||||
|
||||
def forward_events(event):
|
||||
@@ -125,13 +142,19 @@ def _handle_get_api_stream(handler, path_match, data):
|
||||
block.set()
|
||||
return
|
||||
|
||||
handler.server.sessions.extend_validation(session_id)
|
||||
write_message(json.dumps(event, cls=rem.JSONEncoder))
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header('Content-type', 'text/event-stream')
|
||||
session_id = handler.set_session_cookie_header()
|
||||
handler.end_headers()
|
||||
|
||||
hass.bus.listen(MATCH_ALL, forward_events)
|
||||
if restrict:
|
||||
for event in restrict:
|
||||
hass.bus.listen(event, forward_events)
|
||||
else:
|
||||
hass.bus.listen(MATCH_ALL, forward_events)
|
||||
|
||||
while True:
|
||||
write_message(STREAM_PING_PAYLOAD)
|
||||
@@ -145,7 +168,11 @@ def _handle_get_api_stream(handler, path_match, data):
|
||||
_LOGGER.info("Found broken event stream to %s, cleaning up",
|
||||
handler.client_address[0])
|
||||
|
||||
hass.bus.remove_listener(MATCH_ALL, forward_events)
|
||||
if restrict:
|
||||
for event in restrict:
|
||||
hass.bus.remove_listener(event, forward_events)
|
||||
else:
|
||||
hass.bus.remove_listener(MATCH_ALL, forward_events)
|
||||
|
||||
|
||||
def _handle_get_api_config(handler, path_match, data):
|
||||
@@ -334,6 +361,35 @@ def _handle_get_api_components(handler, path_match, data):
|
||||
handler.write_json(handler.server.hass.config.components)
|
||||
|
||||
|
||||
def _handle_get_api_error_log(handler, path_match, data):
|
||||
""" Returns the logged errors for this session. """
|
||||
handler.write_file(handler.server.hass.config.path(ERROR_LOG_FILENAME),
|
||||
False)
|
||||
|
||||
|
||||
def _handle_post_api_log_out(handler, path_match, data):
|
||||
""" Log user out. """
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.destroy_session()
|
||||
handler.end_headers()
|
||||
|
||||
|
||||
def _handle_post_api_template(handler, path_match, data):
|
||||
""" Log user out. """
|
||||
template_string = data.get('template', '')
|
||||
|
||||
try:
|
||||
rendered = template.render(handler.server.hass, template_string)
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(rendered.encode('utf-8'))
|
||||
except TemplateError as e:
|
||||
handler.write_json_message(str(e), HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
|
||||
def _services_json(hass):
|
||||
""" Generate services data to JSONify. """
|
||||
return [{"domain": key, "services": value}
|
||||
|
@@ -4,26 +4,8 @@ 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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/arduino/
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -37,7 +19,6 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
|
||||
DOMAIN = "arduino"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['PyMata==2.07a']
|
||||
BOARD = None
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
@@ -1,74 +1,225 @@
|
||||
"""
|
||||
homeassistant.components.automation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Allows to setup simple automation rules via the config file.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/automation/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.bootstrap import prepare_setup_platform
|
||||
from homeassistant.helpers import config_per_platform
|
||||
from homeassistant.util import split_entity_id
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM
|
||||
from homeassistant.components import logbook
|
||||
|
||||
DOMAIN = "automation"
|
||||
DOMAIN = 'automation'
|
||||
|
||||
DEPENDENCIES = ["group"]
|
||||
DEPENDENCIES = ['group']
|
||||
|
||||
CONF_ALIAS = "alias"
|
||||
CONF_SERVICE = "execute_service"
|
||||
CONF_SERVICE_ENTITY_ID = "service_entity_id"
|
||||
CONF_SERVICE_DATA = "service_data"
|
||||
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. """
|
||||
success = False
|
||||
config_key = DOMAIN
|
||||
found = 1
|
||||
|
||||
for p_type, p_config in config_per_platform(config, DOMAIN, _LOGGER):
|
||||
platform = prepare_setup_platform(hass, config, DOMAIN, p_type)
|
||||
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)
|
||||
|
||||
if platform is None:
|
||||
_LOGGER.error("Unknown automation platform specified: %s", p_type)
|
||||
continue
|
||||
# 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)
|
||||
|
||||
if platform.register(hass, p_config, _get_action(hass, p_config)):
|
||||
_LOGGER.info(
|
||||
"Initialized %s rule %s", p_type, p_config.get(CONF_ALIAS, ""))
|
||||
success = True
|
||||
# any scalar value is incorrect
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Error setting up rule %s", p_config.get(CONF_ALIAS, ""))
|
||||
_LOGGER.error('Error in config in section %s.', config_key)
|
||||
|
||||
return success
|
||||
found += 1
|
||||
config_key = "{} {}".format(DOMAIN, found)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _get_action(hass, config):
|
||||
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 rule %s", config.get(CONF_ALIAS, ""))
|
||||
_LOGGER.info('Executing %s', name)
|
||||
logbook.log_entry(hass, name, 'has been triggered', DOMAIN)
|
||||
|
||||
if CONF_SERVICE in config:
|
||||
domain, service = split_entity_id(config[CONF_SERVICE])
|
||||
domain, service = split_entity_id(config[CONF_SERVICE])
|
||||
service_data = config.get(CONF_SERVICE_DATA, {})
|
||||
|
||||
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 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]
|
||||
|
||||
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)
|
||||
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/')
|
||||
|
||||
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
|
||||
|
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
homeassistant.components.automation.event
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers event listening automation rules.
|
||||
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#event-trigger
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -12,7 +14,7 @@ CONF_EVENT_DATA = "event_data"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register(hass, config, action):
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for events based on config. """
|
||||
event_type = config.get(CONF_EVENT_TYPE)
|
||||
|
||||
@@ -20,11 +22,12 @@ def register(hass, config, action):
|
||||
_LOGGER.error("Missing configuration key %s", CONF_EVENT_TYPE)
|
||||
return False
|
||||
|
||||
event_data = config.get(CONF_EVENT_DATA, {})
|
||||
event_data = config.get(CONF_EVENT_DATA)
|
||||
|
||||
def handle_event(event):
|
||||
""" Listens for events and calls the action when data matches. """
|
||||
if event_data == event.data:
|
||||
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)
|
||||
|
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
homeassistant.components.automation.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers MQTT listening automation rules.
|
||||
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#mqtt-trigger
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -10,11 +12,11 @@ import homeassistant.components.mqtt as mqtt
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
CONF_TOPIC = 'mqtt_topic'
|
||||
CONF_PAYLOAD = 'mqtt_payload'
|
||||
CONF_TOPIC = 'topic'
|
||||
CONF_PAYLOAD = 'payload'
|
||||
|
||||
|
||||
def register(hass, config, action):
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
topic = config.get(CONF_TOPIC)
|
||||
payload = config.get(CONF_PAYLOAD)
|
||||
|
111
homeassistant/components/automation/numeric_state.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
homeassistant.components.automation.numeric_state
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Offers numeric state listening automation rules.
|
||||
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#numeric-state-trigger
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_VALUE_TEMPLATE
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.util import template
|
||||
|
||||
|
||||
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)
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
|
||||
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
|
||||
|
||||
if value_template is not None:
|
||||
renderer = lambda value: template.render(hass,
|
||||
value_template,
|
||||
{'state': value})
|
||||
else:
|
||||
renderer = lambda value: value.state
|
||||
|
||||
# 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(above, below, renderer(to_s)) and \
|
||||
(from_s is None or not _in_range(above, below, renderer(from_s))):
|
||||
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)
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
|
||||
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
|
||||
|
||||
if value_template is not None:
|
||||
renderer = lambda value: template.render(hass,
|
||||
value_template,
|
||||
{'state': value})
|
||||
else:
|
||||
renderer = lambda value: value.state
|
||||
|
||||
def if_numeric_state():
|
||||
""" Test numeric state condition. """
|
||||
state = hass.states.get(entity_id)
|
||||
return state is not None and _in_range(above, below, renderer(state))
|
||||
|
||||
return if_numeric_state
|
||||
|
||||
|
||||
def _in_range(range_start, range_end, value):
|
||||
""" Checks if value is inside the range """
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Value returned from template is not a number: %s",
|
||||
value)
|
||||
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
|
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
homeassistant.components.automation.state
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers state listening automation rules.
|
||||
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#state-trigger
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -10,22 +12,28 @@ from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.const import MATCH_ALL
|
||||
|
||||
|
||||
CONF_ENTITY_ID = "state_entity_id"
|
||||
CONF_FROM = "state_from"
|
||||
CONF_TO = "state_to"
|
||||
CONF_ENTITY_ID = "entity_id"
|
||||
CONF_FROM = "from"
|
||||
CONF_TO = "to"
|
||||
CONF_STATE = "state"
|
||||
|
||||
|
||||
def register(hass, config, action):
|
||||
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 configuration key %s", CONF_ENTITY_ID)
|
||||
"Missing trigger configuration key %s", CONF_ENTITY_ID)
|
||||
return False
|
||||
|
||||
from_state = config.get(CONF_FROM, MATCH_ALL)
|
||||
to_state = config.get(CONF_TO, MATCH_ALL)
|
||||
to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL
|
||||
|
||||
if isinstance(from_state, bool) or isinstance(to_state, bool):
|
||||
logging.getLogger(__name__).error(
|
||||
'Config error. Surround to/from values with quotes.')
|
||||
return False
|
||||
|
||||
def state_automation_listener(entity, from_s, to_s):
|
||||
""" Listens for state changes and calls action. """
|
||||
@@ -35,3 +43,23 @@ def register(hass, config, action):
|
||||
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
|
||||
|
105
homeassistant/components/automation/sun.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
homeassistant.components.automation.sun
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Offers sun based automation rules.
|
||||
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#sun-trigger
|
||||
"""
|
||||
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())
|
65
homeassistant/components/automation/template.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
homeassistant.components.automation.template
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Offers template automation rules.
|
||||
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#template-trigger
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_VALUE_TEMPLATE, EVENT_STATE_CHANGED
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.util import template
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
|
||||
if value_template is None:
|
||||
_LOGGER.error("Missing configuration key %s", CONF_VALUE_TEMPLATE)
|
||||
return False
|
||||
|
||||
# Local variable to keep track of if the action has already been triggered
|
||||
already_triggered = False
|
||||
|
||||
def event_listener(event):
|
||||
""" Listens for state changes and calls action. """
|
||||
nonlocal already_triggered
|
||||
template_result = _check_template(hass, value_template)
|
||||
|
||||
# Check to see if template returns true
|
||||
if template_result and not already_triggered:
|
||||
already_triggered = True
|
||||
action()
|
||||
elif not template_result:
|
||||
already_triggered = False
|
||||
|
||||
hass.bus.listen(EVENT_STATE_CHANGED, event_listener)
|
||||
return True
|
||||
|
||||
|
||||
def if_action(hass, config):
|
||||
""" Wraps action method with state based condition. """
|
||||
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
|
||||
if value_template is None:
|
||||
_LOGGER.error("Missing configuration key %s", CONF_VALUE_TEMPLATE)
|
||||
return False
|
||||
|
||||
return lambda: _check_template(hass, value_template)
|
||||
|
||||
|
||||
def _check_template(hass, value_template):
|
||||
""" Checks if result of template is true """
|
||||
try:
|
||||
value = template.render(hass, value_template, {})
|
||||
except TemplateError:
|
||||
_LOGGER.exception('Error parsing template')
|
||||
return False
|
||||
|
||||
return value.lower() == 'true'
|
@@ -1,22 +1,46 @@
|
||||
"""
|
||||
homeassistant.components.automation.time
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers time listening automation rules.
|
||||
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#time-trigger
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.util import convert
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers.event import track_time_change
|
||||
|
||||
CONF_HOURS = "time_hours"
|
||||
CONF_MINUTES = "time_minutes"
|
||||
CONF_SECONDS = "time_seconds"
|
||||
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 register(hass, config, action):
|
||||
def trigger(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
hours = convert(config.get(CONF_HOURS), int)
|
||||
minutes = convert(config.get(CONF_MINUTES), int)
|
||||
seconds = convert(config.get(CONF_SECONDS), int)
|
||||
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. """
|
||||
@@ -26,3 +50,58 @@ def register(hass, config, action):
|
||||
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')
|
||||
|
88
homeassistant/components/automation/zone.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
homeassistant.components.automation.zone
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Offers zone automation rules.
|
||||
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#zone-trigger
|
||||
"""
|
||||
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)
|
||||
|
||||
# pylint: disable=too-many-boolean-expressions
|
||||
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))
|
49
homeassistant/components/binary_sensor/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
homeassistant.components.binary_sensor
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Component to interface with binary sensors (sensors which only know two states)
|
||||
that can be monitored.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.const import (STATE_ON, STATE_OFF)
|
||||
|
||||
DOMAIN = 'binary_sensor'
|
||||
SCAN_INTERVAL = 30
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Track states and offer events for binary sensors. """
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
component.setup(config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
class BinarySensorDevice(Entity):
|
||||
""" Represents a binary sensor. """
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if the binary sensor is on. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the binary sensor. """
|
||||
return STATE_ON if self.is_on else STATE_OFF
|
||||
|
||||
@property
|
||||
def friendly_state(self):
|
||||
""" Returns the friendly state of the binary sensor. """
|
||||
return None
|
107
homeassistant/components/binary_sensor/arest.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
homeassistant.components.binary_sensor.arest
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
The arest sensor will consume an exposed aREST API of a device.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.arest/
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
|
||||
|
||||
CONF_RESOURCE = 'resource'
|
||||
CONF_PIN = 'pin'
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Get the aREST binary sensor. """
|
||||
|
||||
resource = config.get(CONF_RESOURCE)
|
||||
pin = config.get(CONF_PIN)
|
||||
|
||||
if None in (resource, pin):
|
||||
_LOGGER.error('Not all required config keys present: %s',
|
||||
', '.join((CONF_RESOURCE, CONF_PIN)))
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.get(resource, timeout=10).json()
|
||||
except requests.exceptions.MissingSchema:
|
||||
_LOGGER.error('Missing resource or schema in configuration. '
|
||||
'Add http:// to your URL.')
|
||||
return False
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.error('No route to device at %s. '
|
||||
'Please check the IP address in the configuration file.',
|
||||
resource)
|
||||
return False
|
||||
|
||||
arest = ArestData(resource, pin)
|
||||
|
||||
add_devices([ArestBinarySensor(arest,
|
||||
resource,
|
||||
config.get('name', response['name']),
|
||||
pin)])
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes, too-many-arguments
|
||||
class ArestBinarySensor(BinarySensorDevice):
|
||||
""" Implements an aREST binary sensor for a pin. """
|
||||
|
||||
def __init__(self, arest, resource, name, pin):
|
||||
self.arest = arest
|
||||
self._resource = resource
|
||||
self._name = name
|
||||
self._pin = pin
|
||||
self.update()
|
||||
|
||||
if self._pin is not None:
|
||||
request = requests.get('{}/mode/{}/i'.format
|
||||
(self._resource, self._pin), timeout=10)
|
||||
if request.status_code is not 200:
|
||||
_LOGGER.error("Can't set mode. Is device offline?")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" The name of the binary sensor. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if the binary sensor is on. """
|
||||
return bool(self.arest.data.get('state'))
|
||||
|
||||
def update(self):
|
||||
""" Gets the latest data from aREST API. """
|
||||
self.arest.update()
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class ArestData(object):
|
||||
""" Class for handling the data retrieval for pins. """
|
||||
|
||||
def __init__(self, resource, pin):
|
||||
self._resource = resource
|
||||
self._pin = pin
|
||||
self.data = {}
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
""" Gets the latest data from aREST device. """
|
||||
try:
|
||||
response = requests.get('{}/digital/{}'.format(
|
||||
self._resource, self._pin), timeout=10)
|
||||
self.data = {'state': response.json()['return_value']}
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.error("No route to device '%s'. Is device offline?",
|
||||
self._resource)
|
37
homeassistant/components/binary_sensor/demo.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
homeassistant.components.binary_sensor.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Demo platform that has two fake binary sensors.
|
||||
"""
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the Demo binary sensors. """
|
||||
add_devices([
|
||||
DemoBinarySensor('Basement Floor Wet', False),
|
||||
DemoBinarySensor('Movement Backyard', True),
|
||||
])
|
||||
|
||||
|
||||
class DemoBinarySensor(BinarySensorDevice):
|
||||
""" A Demo binary sensor. """
|
||||
|
||||
def __init__(self, name, state):
|
||||
self._name = name
|
||||
self._state = state
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" No polling needed for a demo binary sensor. """
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the binary sensor. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if the binary sensor is on. """
|
||||
return self._state
|
84
homeassistant/components/binary_sensor/mqtt.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
homeassistant.components.binary_sensor.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Allows to configure a MQTT binary sensor.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.mqtt/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_VALUE_TEMPLATE
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.util import template
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'MQTT Binary sensor'
|
||||
DEFAULT_QOS = 0
|
||||
DEFAULT_PAYLOAD_ON = 'ON'
|
||||
DEFAULT_PAYLOAD_OFF = 'OFF'
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Add MQTT binary sensor. """
|
||||
|
||||
if config.get('state_topic') is None:
|
||||
_LOGGER.error('Missing required variable: state_topic')
|
||||
return False
|
||||
|
||||
add_devices([MqttBinarySensor(
|
||||
hass,
|
||||
config.get('name', DEFAULT_NAME),
|
||||
config.get('state_topic', None),
|
||||
config.get('qos', DEFAULT_QOS),
|
||||
config.get('payload_on', DEFAULT_PAYLOAD_ON),
|
||||
config.get('payload_off', DEFAULT_PAYLOAD_OFF),
|
||||
config.get(CONF_VALUE_TEMPLATE))])
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-instance-attributes
|
||||
class MqttBinarySensor(BinarySensorDevice):
|
||||
""" Represents a binary sensor that is updated by MQTT. """
|
||||
def __init__(self, hass, name, state_topic, qos, payload_on, payload_off,
|
||||
value_template):
|
||||
self._hass = hass
|
||||
self._name = name
|
||||
self._state = False
|
||||
self._state_topic = state_topic
|
||||
self._payload_on = payload_on
|
||||
self._payload_off = payload_off
|
||||
self._qos = qos
|
||||
|
||||
def message_received(topic, payload, qos):
|
||||
""" A new MQTT message has been received. """
|
||||
if value_template is not None:
|
||||
payload = template.render_with_possible_json_value(
|
||||
hass, value_template, payload)
|
||||
if payload == self._payload_on:
|
||||
self._state = True
|
||||
self.update_ha_state()
|
||||
elif payload == self._payload_off:
|
||||
self._state = False
|
||||
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):
|
||||
""" The name of the binary sensor. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if the binary sensor is on. """
|
||||
return self._state
|
145
homeassistant/components/binary_sensor/rest.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
homeassistant.components.binary_sensor.rest
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
The rest binary sensor will consume responses sent by an exposed REST API.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.rest/
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_VALUE_TEMPLATE
|
||||
from homeassistant.util import template, Throttle
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'REST Binary Sensor'
|
||||
DEFAULT_METHOD = 'GET'
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
|
||||
|
||||
|
||||
# pylint: disable=unused-variable
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Get the REST binary sensor. """
|
||||
|
||||
use_get = False
|
||||
use_post = False
|
||||
|
||||
resource = config.get('resource', None)
|
||||
method = config.get('method', DEFAULT_METHOD)
|
||||
payload = config.get('payload', None)
|
||||
verify_ssl = config.get('verify_ssl', True)
|
||||
|
||||
if method == 'GET':
|
||||
use_get = True
|
||||
elif method == 'POST':
|
||||
use_post = True
|
||||
|
||||
try:
|
||||
if use_get:
|
||||
response = requests.get(resource, timeout=10, verify=verify_ssl)
|
||||
elif use_post:
|
||||
response = requests.post(resource, data=payload, timeout=10,
|
||||
verify=verify_ssl)
|
||||
if not response.ok:
|
||||
_LOGGER.error('Response status is "%s"', response.status_code)
|
||||
return False
|
||||
except requests.exceptions.MissingSchema:
|
||||
_LOGGER.error('Missing resource or schema in configuration. '
|
||||
'Add http:// to your URL.')
|
||||
return False
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.error('No route to resource/endpoint: %s',
|
||||
resource)
|
||||
return False
|
||||
|
||||
if use_get:
|
||||
rest = RestDataGet(resource, verify_ssl)
|
||||
elif use_post:
|
||||
rest = RestDataPost(resource, payload, verify_ssl)
|
||||
|
||||
add_devices([RestBinarySensor(hass,
|
||||
rest,
|
||||
config.get('name', DEFAULT_NAME),
|
||||
config.get(CONF_VALUE_TEMPLATE))])
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
class RestBinarySensor(BinarySensorDevice):
|
||||
""" Implements a REST binary sensor. """
|
||||
|
||||
def __init__(self, hass, rest, name, value_template):
|
||||
self._hass = hass
|
||||
self.rest = rest
|
||||
self._name = name
|
||||
self._state = False
|
||||
self._value_template = value_template
|
||||
self.update()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" The name of the binary sensor. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if the binary sensor is on. """
|
||||
if self.rest.data is False:
|
||||
return False
|
||||
else:
|
||||
if self._value_template is not None:
|
||||
self.rest.data = template.render_with_possible_json_value(
|
||||
self._hass, self._value_template, self.rest.data, False)
|
||||
return bool(int(self.rest.data))
|
||||
|
||||
def update(self):
|
||||
""" Gets the latest data from REST API and updates the state. """
|
||||
self.rest.update()
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class RestDataGet(object):
|
||||
""" Class for handling the data retrieval with GET method. """
|
||||
|
||||
def __init__(self, resource, verify_ssl):
|
||||
self._resource = resource
|
||||
self._verify_ssl = verify_ssl
|
||||
self.data = False
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
""" Gets the latest data from REST service with GET method. """
|
||||
try:
|
||||
response = requests.get(self._resource, timeout=10,
|
||||
verify=self._verify_ssl)
|
||||
self.data = response.text
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.error("No route to resource/endpoint: %s", self._resource)
|
||||
self.data = False
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class RestDataPost(object):
|
||||
""" Class for handling the data retrieval with POST method. """
|
||||
|
||||
def __init__(self, resource, payload, verify_ssl):
|
||||
self._resource = resource
|
||||
self._payload = payload
|
||||
self._verify_ssl = verify_ssl
|
||||
self.data = False
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
""" Gets the latest data from REST service with POST method. """
|
||||
try:
|
||||
response = requests.post(self._resource, data=self._payload,
|
||||
timeout=10, verify=self._verify_ssl)
|
||||
self.data = response.text
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.error("No route to resource/endpoint: %s", self._resource)
|
||||
self.data = False
|
@@ -1,12 +1,13 @@
|
||||
"""
|
||||
homeassistant.components.browser
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to launch a webbrowser on the host machine.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/browser/
|
||||
"""
|
||||
|
||||
DOMAIN = "browser"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_BROWSE_URL = "browse_url"
|
||||
|
||||
|
@@ -4,38 +4,23 @@ 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
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera/
|
||||
"""
|
||||
import requests
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_PICTURE,
|
||||
HTTP_NOT_FOUND,
|
||||
ATTR_ENTITY_ID,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
|
||||
DOMAIN = 'camera'
|
||||
DEPENDENCIES = ['http']
|
||||
@@ -74,7 +59,7 @@ 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. """
|
||||
""" Track states and offer events for cameras. """
|
||||
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL,
|
||||
@@ -96,16 +81,21 @@ def setup(hass, config):
|
||||
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 = component.entities.get(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:
|
||||
if camera is None:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
handler.end_headers()
|
||||
return
|
||||
|
||||
response = camera.camera_image()
|
||||
|
||||
if response is None:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
handler.end_headers()
|
||||
return
|
||||
|
||||
handler.wfile.write(response)
|
||||
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
@@ -114,18 +104,16 @@ def setup(hass, config):
|
||||
|
||||
# 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.
|
||||
"""
|
||||
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 = component.entities.get(entity_id)
|
||||
|
||||
camera = None
|
||||
if entity_id in component.entities.keys():
|
||||
camera = component.entities[entity_id]
|
||||
|
||||
if not camera:
|
||||
if camera is None:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
handler.end_headers()
|
||||
return
|
||||
@@ -143,9 +131,9 @@ def setup(hass, config):
|
||||
# MJPEG_START_HEADER.format()
|
||||
|
||||
while True:
|
||||
|
||||
img_bytes = camera.camera_image()
|
||||
|
||||
if img_bytes is None:
|
||||
continue
|
||||
headers_str = '\r\n'.join((
|
||||
'Content-length: {}'.format(len(img_bytes)),
|
||||
'Content-type: image/jpeg',
|
||||
@@ -159,12 +147,12 @@ def setup(hass, config):
|
||||
handler.request.sendall(
|
||||
bytes('--jpgboundary\r\n', 'utf-8'))
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
except (requests.RequestException, IOError):
|
||||
camera.is_streaming = False
|
||||
camera.update_ha_state()
|
||||
|
||||
camera.is_streaming = False
|
||||
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(
|
||||
@@ -175,7 +163,7 @@ def setup(hass, config):
|
||||
|
||||
|
||||
class Camera(Entity):
|
||||
""" The base class for camera components """
|
||||
""" The base class for camera components. """
|
||||
|
||||
def __init__(self):
|
||||
self.is_streaming = False
|
||||
@@ -183,23 +171,23 @@ class Camera(Entity):
|
||||
@property
|
||||
# pylint: disable=no-self-use
|
||||
def is_recording(self):
|
||||
""" Returns true if the device is recording """
|
||||
""" 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 """
|
||||
""" Should return a string of the camera brand. """
|
||||
return None
|
||||
|
||||
@property
|
||||
# pylint: disable=no-self-use
|
||||
def model(self):
|
||||
""" Returns string of camera model """
|
||||
""" Returns string of camera model. """
|
||||
return None
|
||||
|
||||
def camera_image(self):
|
||||
""" Return bytes of camera image """
|
||||
""" Return bytes of camera image. """
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
|
37
homeassistant/components/camera/demo.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
homeassistant.components.camera.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Demo platform that has a fake camera.
|
||||
"""
|
||||
import os
|
||||
from homeassistant.components.camera import Camera
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the Demo camera. """
|
||||
add_devices([
|
||||
DemoCamera('Demo camera')
|
||||
])
|
||||
|
||||
|
||||
class DemoCamera(Camera):
|
||||
""" A Demo camera. """
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__()
|
||||
self._name = name
|
||||
|
||||
def camera_image(self):
|
||||
""" Return a faked still image response. """
|
||||
now = dt_util.utcnow()
|
||||
|
||||
image_path = os.path.join(os.path.dirname(__file__),
|
||||
'demo_{}.jpg'.format(now.second % 4))
|
||||
with open(image_path, 'rb') as file:
|
||||
return file.read()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Return the name of this device. """
|
||||
return self._name
|
BIN
homeassistant/components/camera/demo_0.jpg
Normal file
After Width: | Height: | Size: 43 KiB |
BIN
homeassistant/components/camera/demo_1.jpg
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
homeassistant/components/camera/demo_2.jpg
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
homeassistant/components/camera/demo_3.jpg
Normal file
After Width: | Height: | Size: 44 KiB |
61
homeassistant/components/camera/foscam.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
homeassistant.components.camera.foscam
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
This component provides basic support for Foscam IP cameras.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.foscam/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.camera import DOMAIN, Camera
|
||||
|
||||
_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=snapPicture2&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 and return raw jpg data
|
||||
response = requests.get(self._snap_picture_url)
|
||||
|
||||
return response.content
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Return the name of this device. """
|
||||
return self._name
|
@@ -1,55 +1,18 @@
|
||||
"""
|
||||
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 config/configuration.yaml:
|
||||
|
||||
camera:
|
||||
platform: generic
|
||||
name: Door Camera
|
||||
username: YOUR_USERNAME
|
||||
password: YOUR_PASSWORD
|
||||
still_image_url: http://YOUR_CAMERA_IP_AND_PORT/image.jpg
|
||||
|
||||
|
||||
VARIABLES:
|
||||
|
||||
These are the variables for the device_data array:
|
||||
|
||||
still_image_url
|
||||
*Required
|
||||
The URL your camera serves the image on.
|
||||
Example: http://192.168.1.21:2112/
|
||||
|
||||
name
|
||||
*Optional
|
||||
This parameter allows you to override the name of your camera in homeassistant
|
||||
|
||||
username
|
||||
*Optional
|
||||
THe username for acessing your camera
|
||||
|
||||
password
|
||||
*Optional
|
||||
the password for accessing your camera
|
||||
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.generic/
|
||||
"""
|
||||
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
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.camera import DOMAIN, Camera
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -78,17 +41,25 @@ class GenericCamera(Camera):
|
||||
self._still_image_url = device_info['still_image_url']
|
||||
|
||||
def camera_image(self):
|
||||
""" Return a still image reponse from the camera """
|
||||
""" Return a still image response from the camera. """
|
||||
if self._username and self._password:
|
||||
response = requests.get(
|
||||
self._still_image_url,
|
||||
auth=HTTPBasicAuth(self._username, self._password))
|
||||
try:
|
||||
response = requests.get(
|
||||
self._still_image_url,
|
||||
auth=HTTPBasicAuth(self._username, self._password))
|
||||
except requests.exceptions.RequestException as error:
|
||||
_LOGGER.error('Error getting camera image: %s', error)
|
||||
return None
|
||||
else:
|
||||
response = requests.get(self._still_image_url)
|
||||
try:
|
||||
response = requests.get(self._still_image_url)
|
||||
except requests.exceptions.RequestException as error:
|
||||
_LOGGER.error('Error getting camera image: %s', error)
|
||||
return None
|
||||
|
||||
return response.content
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Return the name of this device """
|
||||
""" Return the name of this device. """
|
||||
return self._name
|
||||
|
72
homeassistant/components/camera/mjpeg.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
homeassistant.components.camera.mjpeg
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for IP Cameras.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.mjpeg/
|
||||
"""
|
||||
from contextlib import closing
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.camera import DOMAIN, Camera
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Adds a mjpeg IP Camera. """
|
||||
if not validate_config({DOMAIN: config}, {DOMAIN: ['mjpeg_url']},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
add_devices_callback([MjpegCamera(config)])
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class MjpegCamera(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', 'Mjpeg Camera')
|
||||
self._username = device_info.get('username')
|
||||
self._password = device_info.get('password')
|
||||
self._mjpeg_url = device_info['mjpeg_url']
|
||||
|
||||
def camera_image(self):
|
||||
""" Return a still image response from the camera. """
|
||||
|
||||
def process_response(response):
|
||||
""" Take in a response object, return the jpg from it. """
|
||||
data = b''
|
||||
for chunk in response.iter_content(1024):
|
||||
data += chunk
|
||||
jpg_start = data.find(b'\xff\xd8')
|
||||
jpg_end = data.find(b'\xff\xd9')
|
||||
if jpg_start != -1 and jpg_end != -1:
|
||||
jpg = data[jpg_start:jpg_end + 2]
|
||||
return jpg
|
||||
|
||||
if self._username and self._password:
|
||||
with closing(requests.get(self._mjpeg_url,
|
||||
auth=HTTPBasicAuth(self._username,
|
||||
self._password),
|
||||
stream=True)) as response:
|
||||
return process_response(response)
|
||||
else:
|
||||
with closing(requests.get(self._mjpeg_url,
|
||||
stream=True)) as response:
|
||||
return process_response(response)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Return the name of this device. """
|
||||
return self._name
|
@@ -15,7 +15,6 @@ from homeassistant.helpers import generate_entity_id
|
||||
from homeassistant.const import EVENT_TIME_CHANGED
|
||||
|
||||
DOMAIN = "configurator"
|
||||
DEPENDENCIES = []
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
SERVICE_CONFIGURE = "configure"
|
||||
|
@@ -1,19 +1,20 @@
|
||||
"""
|
||||
homeassistant.components.conversation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to have conversations with Home Assistant.
|
||||
This is more a proof of concept.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/conversation/
|
||||
"""
|
||||
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"
|
||||
|
||||
@@ -21,9 +22,13 @@ ATTR_TEXT = "text"
|
||||
|
||||
REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)')
|
||||
|
||||
REQUIREMENTS = ['fuzzywuzzy==0.8.0']
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Registers the process service. """
|
||||
from fuzzywuzzy import process as fuzzyExtract
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def process(service):
|
||||
@@ -42,9 +47,11 @@ def setup(hass, config):
|
||||
|
||||
name, command = match.groups()
|
||||
|
||||
entity_ids = [
|
||||
state.entity_id for state in hass.states.all()
|
||||
if state.name.lower() == name]
|
||||
entities = {state.entity_id: state.name for state in hass.states.all()}
|
||||
|
||||
entity_ids = fuzzyExtract.extractOne(name,
|
||||
entities,
|
||||
score_cutoff=65)[2]
|
||||
|
||||
if not entity_ids:
|
||||
logger.error(
|
||||
|
@@ -10,14 +10,26 @@ 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)
|
||||
CONF_PLATFORM, ATTR_ENTITY_ID)
|
||||
|
||||
DOMAIN = "demo"
|
||||
|
||||
DEPENDENCIES = ['introduction', 'conversation']
|
||||
DEPENDENCIES = ['conversation', 'introduction', 'zone']
|
||||
|
||||
COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
'switch', 'light', 'thermostat', 'sensor', 'media_player', 'notify']
|
||||
'alarm_control_panel',
|
||||
'binary_sensor',
|
||||
'camera',
|
||||
'device_tracker',
|
||||
'light',
|
||||
'lock',
|
||||
'media_player',
|
||||
'notify',
|
||||
'rollershutter',
|
||||
'sensor',
|
||||
'switch',
|
||||
'thermostat',
|
||||
]
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
@@ -33,17 +45,18 @@ def setup(hass, config):
|
||||
|
||||
# Setup sun
|
||||
if not hass.config.latitude:
|
||||
hass.config.latitude = '32.87336'
|
||||
hass.config.latitude = 32.87336
|
||||
|
||||
if not hass.config.longitude:
|
||||
hass.config.longitude = '117.22743'
|
||||
hass.config.longitude = 117.22743
|
||||
|
||||
bootstrap.setup_component(hass, 'sun')
|
||||
|
||||
# Setup demo platforms
|
||||
demo_config = config.copy()
|
||||
for component in COMPONENTS_WITH_DEMO_PLATFORM:
|
||||
bootstrap.setup_component(
|
||||
hass, component, {component: {CONF_PLATFORM: 'demo'}})
|
||||
demo_config[component] = {CONF_PLATFORM: 'demo'}
|
||||
bootstrap.setup_component(hass, component, demo_config)
|
||||
|
||||
# Setup room groups
|
||||
lights = sorted(hass.states.entity_ids('light'))
|
||||
@@ -54,15 +67,6 @@ def setup(hass, config):
|
||||
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://194.218.96.92/jpg/image.jpg',
|
||||
}})
|
||||
|
||||
# Setup scripts
|
||||
bootstrap.setup_component(
|
||||
hass, 'script',
|
||||
@@ -102,23 +106,6 @@ def setup(hass, config):
|
||||
}},
|
||||
]})
|
||||
|
||||
# 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'})
|
||||
|
||||
hass.states.set("group.all_devices", "home",
|
||||
{
|
||||
"auto": True,
|
||||
ATTR_ENTITY_ID: [
|
||||
"device_tracker.paulus",
|
||||
"device_tracker.anne_therese"
|
||||
]
|
||||
})
|
||||
|
||||
# Setup configurator
|
||||
configurator_ids = []
|
||||
|
||||
|
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
homeassistant.components.device_sun_light_trigger
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides functionality to turn on lights based on the state of the sun and
|
||||
devices.
|
||||
|
||||
Provides functionality to turn on lights based on
|
||||
the state of the sun and devices.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_sun_light_trigger/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
@@ -1,52 +1,75 @@
|
||||
"""
|
||||
homeassistant.components.tracker
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
homeassistant.components.device_tracker
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides functionality to keep track of devices.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker/
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
import os
|
||||
# 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.helpers import validate_config
|
||||
from homeassistant.helpers.entity import _OVERWRITE
|
||||
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.bootstrap import prepare_setup_platform
|
||||
|
||||
from homeassistant.helpers.event import track_utc_time_change
|
||||
from homeassistant.const import (
|
||||
STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME,
|
||||
CONF_PLATFORM, DEVICE_DEFAULT_NAME)
|
||||
from homeassistant.components import group
|
||||
ATTR_ENTITY_PICTURE, ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
|
||||
DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME)
|
||||
|
||||
DOMAIN = "device_tracker"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_DEVICE_TRACKER_RELOAD = "reload_devices_csv"
|
||||
DEPENDENCIES = ['zone']
|
||||
|
||||
GROUP_NAME_ALL_DEVICES = 'all devices'
|
||||
ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('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_DEVICE_NOT_FOUND = timedelta(minutes=3)
|
||||
CSV_DEVICES = "known_devices.csv"
|
||||
YAML_DEVICES = 'known_devices.yaml'
|
||||
|
||||
# Filename to save known devices to
|
||||
KNOWN_DEVICES_FILE = "known_devices.csv"
|
||||
CONF_TRACK_NEW = "track_new_devices"
|
||||
DEFAULT_CONF_TRACK_NEW = True
|
||||
|
||||
CONF_SECONDS = "interval_seconds"
|
||||
CONF_CONSIDER_HOME = 'consider_home'
|
||||
DEFAULT_CONSIDER_HOME = 180 # seconds
|
||||
|
||||
DEFAULT_CONF_SECONDS = 12
|
||||
CONF_SCAN_INTERVAL = "interval_seconds"
|
||||
DEFAULT_SCAN_INTERVAL = 12
|
||||
|
||||
TRACK_NEW_DEVICES = "track_new_devices"
|
||||
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. """
|
||||
@@ -55,293 +78,356 @@ def is_on(hass, entity_id=None):
|
||||
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):
|
||||
""" Sets up the device tracker. """
|
||||
""" 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)
|
||||
|
||||
if not validate_config(config, {DOMAIN: [CONF_PLATFORM]}, _LOGGER):
|
||||
return False
|
||||
conf = config.get(DOMAIN, {})
|
||||
if isinstance(conf, list):
|
||||
conf = conf[0]
|
||||
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)
|
||||
|
||||
tracker_type = config[DOMAIN].get(CONF_PLATFORM)
|
||||
devices = load_config(yaml_path, hass, consider_home, home_range)
|
||||
tracker = DeviceTracker(hass, consider_home, track_new, home_range,
|
||||
devices)
|
||||
|
||||
tracker_implementation = \
|
||||
prepare_setup_platform(hass, config, DOMAIN, tracker_type)
|
||||
|
||||
if tracker_implementation is None:
|
||||
_LOGGER.error("Unknown device_tracker type specified: %s.",
|
||||
tracker_type)
|
||||
|
||||
return False
|
||||
|
||||
device_scanner = tracker_implementation.get_scanner(hass, config)
|
||||
|
||||
if device_scanner is None:
|
||||
_LOGGER.error("Failed to initialize device scanner: %s",
|
||||
tracker_type)
|
||||
|
||||
return False
|
||||
|
||||
seconds = util.convert(config[DOMAIN].get(CONF_SECONDS), int,
|
||||
DEFAULT_CONF_SECONDS)
|
||||
|
||||
track_new_devices = config[DOMAIN].get(TRACK_NEW_DEVICES) or False
|
||||
_LOGGER.info("Tracking new devices: %s", track_new_devices)
|
||||
|
||||
tracker = DeviceTracker(hass, device_scanner, seconds, track_new_devices)
|
||||
|
||||
# We only succeeded if we got to parse the known devices file
|
||||
return not tracker.invalid_known_devices_file
|
||||
|
||||
|
||||
class DeviceTracker(object):
|
||||
""" Class that tracks which devices are home and which are not. """
|
||||
|
||||
def __init__(self, hass, device_scanner, seconds, track_new_devices):
|
||||
self.hass = hass
|
||||
|
||||
self.device_scanner = device_scanner
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Do we track new devices by default?
|
||||
self.track_new_devices = track_new_devices
|
||||
|
||||
# Dictionary to keep track of known devices and devices we track
|
||||
self.tracked = {}
|
||||
self.untracked_devices = set()
|
||||
|
||||
# Did we encounter an invalid known devices file
|
||||
self.invalid_known_devices_file = False
|
||||
|
||||
# Wrap it in a func instead of lambda so it can be identified in
|
||||
# the bus by its __name__ attribute.
|
||||
def update_device_state(now):
|
||||
""" Triggers update of the device states. """
|
||||
self.update_devices(now)
|
||||
|
||||
dev_group = group.Group(
|
||||
hass, GROUP_NAME_ALL_DEVICES, user_defined=False)
|
||||
|
||||
def reload_known_devices_service(service):
|
||||
""" Reload known devices file. """
|
||||
self._read_known_devices_file()
|
||||
|
||||
self.update_devices(dt_util.utcnow())
|
||||
|
||||
dev_group.update_tracked_entity_ids(self.device_entity_ids)
|
||||
|
||||
reload_known_devices_service(None)
|
||||
|
||||
if self.invalid_known_devices_file:
|
||||
return
|
||||
|
||||
seconds = range(0, 60, seconds)
|
||||
|
||||
_LOGGER.info("Device tracker interval second=%s", seconds)
|
||||
track_utc_time_change(hass, update_device_state, second=seconds)
|
||||
|
||||
hass.services.register(DOMAIN,
|
||||
SERVICE_DEVICE_TRACKER_RELOAD,
|
||||
reload_known_devices_service)
|
||||
|
||||
@property
|
||||
def device_entity_ids(self):
|
||||
""" Returns a set containing all device entity ids
|
||||
that are being tracked. """
|
||||
return set(device['entity_id'] for device in self.tracked.values())
|
||||
|
||||
def _update_state(self, now, device, is_home):
|
||||
""" Update the state of a device. """
|
||||
dev_info = self.tracked[device]
|
||||
|
||||
if is_home:
|
||||
# Update last seen if at home
|
||||
dev_info['last_seen'] = now
|
||||
else:
|
||||
# State remains at home if it has been seen in the last
|
||||
# TIME_DEVICE_NOT_FOUND
|
||||
is_home = now - dev_info['last_seen'] < TIME_DEVICE_NOT_FOUND
|
||||
|
||||
state = STATE_HOME if is_home else STATE_NOT_HOME
|
||||
|
||||
# overwrite properties that have been set in the config file
|
||||
attr = dict(dev_info['state_attr'])
|
||||
attr.update(_OVERWRITE.get(dev_info['entity_id'], {}))
|
||||
|
||||
self.hass.states.set(
|
||||
dev_info['entity_id'], state, attr)
|
||||
|
||||
def update_devices(self, now):
|
||||
""" Update device states based on the found devices. """
|
||||
if not self.lock.acquire(False):
|
||||
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:
|
||||
found_devices = set(dev.upper() for dev in
|
||||
self.device_scanner.scan_devices())
|
||||
if hasattr(platform, 'get_scanner'):
|
||||
scanner = platform.get_scanner(hass, {DOMAIN: p_config})
|
||||
|
||||
for device in self.tracked:
|
||||
is_home = device in found_devices
|
||||
if scanner is None:
|
||||
_LOGGER.error('Error setting up platform %s', p_type)
|
||||
return
|
||||
|
||||
self._update_state(now, device, is_home)
|
||||
setup_scanner_platform(hass, p_config, scanner, tracker.see)
|
||||
return
|
||||
|
||||
if is_home:
|
||||
found_devices.remove(device)
|
||||
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)
|
||||
|
||||
# Did we find any devices that we didn't know about yet?
|
||||
new_devices = found_devices - self.untracked_devices
|
||||
for p_type, p_config in \
|
||||
config_per_platform(config, DOMAIN, _LOGGER):
|
||||
setup_platform(p_type, p_config)
|
||||
|
||||
if new_devices:
|
||||
if not self.track_new_devices:
|
||||
self.untracked_devices.update(new_devices)
|
||||
def device_tracker_discovered(service, info):
|
||||
""" Called when a device tracker platform is discovered. """
|
||||
setup_platform(DISCOVERY_PLATFORMS[service], {}, info)
|
||||
|
||||
self._update_known_devices_file(new_devices)
|
||||
finally:
|
||||
self.lock.release()
|
||||
discovery.listen(hass, DISCOVERY_PLATFORMS.keys(),
|
||||
device_tracker_discovered)
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def _read_known_devices_file(self):
|
||||
""" Parse and process the known devices file. """
|
||||
known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE)
|
||||
def update_stale(now):
|
||||
""" Clean up stale devices. """
|
||||
tracker.update_stale(now)
|
||||
track_utc_time_change(hass, update_stale, second=range(0, 60, 5))
|
||||
|
||||
# Return if no known devices file exists
|
||||
if not os.path.isfile(known_dev_path):
|
||||
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
|
||||
|
||||
self.lock.acquire()
|
||||
elif self.stale():
|
||||
self._state = STATE_NOT_HOME
|
||||
self.last_update_home = False
|
||||
else:
|
||||
self._state = STATE_HOME
|
||||
self.last_update_home = True
|
||||
|
||||
self.untracked_devices.clear()
|
||||
|
||||
with open(known_dev_path) as inp:
|
||||
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
|
||||
|
||||
# To track which devices need an entity_id assigned
|
||||
need_entity_id = []
|
||||
|
||||
# All devices that are still in this set after we read the CSV file
|
||||
# have been removed from the file and thus need to be cleaned up.
|
||||
removed_devices = set(self.tracked.keys())
|
||||
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()]
|
||||
|
||||
try:
|
||||
for row in csv.DictReader(inp):
|
||||
device = row['device'].upper()
|
||||
|
||||
if row['track'] == '1':
|
||||
if device in self.tracked:
|
||||
# Device exists
|
||||
removed_devices.remove(device)
|
||||
else:
|
||||
# We found a new device
|
||||
need_entity_id.append(device)
|
||||
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)
|
||||
|
||||
self._track_device(device, row['name'])
|
||||
# Initial scan of each mac we also tell about host name for config
|
||||
seen = set()
|
||||
|
||||
# Update state_attr with latest from file
|
||||
state_attr = {
|
||||
ATTR_FRIENDLY_NAME: row['name']
|
||||
}
|
||||
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)
|
||||
|
||||
if row['picture']:
|
||||
state_attr[ATTR_ENTITY_PICTURE] = row['picture']
|
||||
track_utc_time_change(hass, device_tracker_scan, second=range(0, 60,
|
||||
interval))
|
||||
|
||||
self.tracked[device]['state_attr'] = state_attr
|
||||
device_tracker_scan(None)
|
||||
|
||||
else:
|
||||
self.untracked_devices.add(device)
|
||||
|
||||
# Remove existing devices that we no longer track
|
||||
for device in removed_devices:
|
||||
entity_id = self.tracked[device]['entity_id']
|
||||
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))
|
||||
|
||||
_LOGGER.info("Removing entity %s", entity_id)
|
||||
|
||||
self.hass.states.remove(entity_id)
|
||||
|
||||
self.tracked.pop(device)
|
||||
|
||||
self._generate_entity_ids(need_entity_id)
|
||||
|
||||
if not self.tracked:
|
||||
_LOGGER.warning(
|
||||
"No devices to track. Please update %s.",
|
||||
known_dev_path)
|
||||
|
||||
_LOGGER.info("Loaded devices from %s", known_dev_path)
|
||||
|
||||
except KeyError:
|
||||
self.invalid_known_devices_file = True
|
||||
|
||||
_LOGGER.warning(
|
||||
("Invalid known devices file: %s. "
|
||||
"We won't update it with new found devices."),
|
||||
known_dev_path)
|
||||
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
def _update_known_devices_file(self, new_devices):
|
||||
""" Add new devices to known devices file. """
|
||||
if not self.invalid_known_devices_file:
|
||||
known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE)
|
||||
|
||||
try:
|
||||
# If file does not exist we will write the header too
|
||||
is_new_file = not os.path.isfile(known_dev_path)
|
||||
|
||||
with open(known_dev_path, 'a') as outp:
|
||||
_LOGGER.info("Found %d new devices, updating %s",
|
||||
len(new_devices), known_dev_path)
|
||||
|
||||
writer = csv.writer(outp)
|
||||
|
||||
if is_new_file:
|
||||
writer.writerow(("device", "name", "track", "picture"))
|
||||
|
||||
for device in new_devices:
|
||||
# See if the device scanner knows the name
|
||||
# else defaults to unknown device
|
||||
name = self.device_scanner.get_device_name(device) or \
|
||||
DEVICE_DEFAULT_NAME
|
||||
|
||||
track = 0
|
||||
if self.track_new_devices:
|
||||
self._track_device(device, name)
|
||||
track = 1
|
||||
|
||||
writer.writerow((device, name, track, ""))
|
||||
|
||||
if self.track_new_devices:
|
||||
self._generate_entity_ids(new_devices)
|
||||
|
||||
except IOError:
|
||||
_LOGGER.exception("Error updating %s with %d new devices",
|
||||
known_dev_path, len(new_devices))
|
||||
|
||||
def _track_device(self, device, name):
|
||||
"""
|
||||
Add a device to the list of tracked devices.
|
||||
Does not generate the entity id yet.
|
||||
"""
|
||||
default_last_seen = dt_util.utcnow().replace(year=1990)
|
||||
|
||||
self.tracked[device] = {
|
||||
'name': name,
|
||||
'last_seen': default_last_seen,
|
||||
'state_attr': {ATTR_FRIENDLY_NAME: name}
|
||||
}
|
||||
|
||||
def _generate_entity_ids(self, need_entity_id):
|
||||
""" Generate entity ids for a list of devices. """
|
||||
# Setup entity_ids for the new devices
|
||||
used_entity_ids = [info['entity_id'] for device, info
|
||||
in self.tracked.items()
|
||||
if device not in need_entity_id]
|
||||
|
||||
for device in need_entity_id:
|
||||
name = self.tracked[device]['name']
|
||||
|
||||
entity_id = util.ensure_unique_string(
|
||||
ENTITY_ID_FORMAT.format(util.slugify(name)),
|
||||
used_entity_ids)
|
||||
|
||||
used_entity_ids.append(entity_id)
|
||||
|
||||
self.tracked[device]['entity_id'] = entity_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))
|
||||
|
@@ -1,42 +1,20 @@
|
||||
"""
|
||||
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 config/configuration.yaml
|
||||
|
||||
device_tracker:
|
||||
platform: actiontec
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.actiontec/
|
||||
"""
|
||||
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
|
||||
@@ -49,74 +27,80 @@ _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}))')
|
||||
r'\smac:\s(?P<mac>([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))' +
|
||||
r'\svalid\sfor:\s(?P<timevalid>(-?\d+))' +
|
||||
r'\ssec')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a DD-WRT scanner. """
|
||||
""" 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.
|
||||
"""
|
||||
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]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
# Test the router is accessible
|
||||
self.last_results = []
|
||||
data = self.get_actiontec_data()
|
||||
self.success_init = data is not None
|
||||
_LOGGER.info("actiontec scanner initialized")
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
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']
|
||||
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. """
|
||||
"""
|
||||
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:
|
||||
# _LOGGER.info("Checking ARP")
|
||||
data = self.get_actiontec_data()
|
||||
if not data:
|
||||
now = dt_util.now()
|
||||
actiontec_data = self.get_actiontec_data()
|
||||
if not actiontec_data:
|
||||
return False
|
||||
active_clients = [client for client in data.values()]
|
||||
self.last_results = active_clients
|
||||
self.last_results = [Device(data['mac'], name, now)
|
||||
for name, data in actiontec_data.items()
|
||||
if data['timevalid'] > -60]
|
||||
_LOGGER.info("actiontec scan successful")
|
||||
return True
|
||||
|
||||
def get_actiontec_data(self):
|
||||
""" Retrieve data from Actiontec MI424WR and return parsed result. """
|
||||
""" Retrieve data from Actiontec MI424WR and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'Username: ')
|
||||
@@ -144,6 +128,7 @@ class ActiontecDeviceScanner(object):
|
||||
if match is not None:
|
||||
devices[match.group('ip')] = {
|
||||
'ip': match.group('ip'),
|
||||
'mac': match.group('mac').upper()
|
||||
'mac': match.group('mac').upper(),
|
||||
'timevalid': int(match.group('timevalid'))
|
||||
}
|
||||
return devices
|
||||
|
@@ -4,33 +4,8 @@ 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 config/configuration.yaml. You also need to enable Telnet in the
|
||||
configuration pages.
|
||||
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.aruba/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
@@ -51,15 +26,7 @@ _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+' +
|
||||
r'(?P<os>([^\s]+))\s+' +
|
||||
r'(?P<network>([^\s]+))\s+' +
|
||||
r'(?P<ap>([^\s]+))\s+' +
|
||||
r'(?P<channel>([^\s]+))\s+' +
|
||||
r'(?P<type>([^\s]+))\s+' +
|
||||
r'(?P<role>([^\s]+))\s+' +
|
||||
r'(?P<signal>([^\s]+))\s+' +
|
||||
r'(?P<speed>([^\s]+))')
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@@ -91,8 +58,9 @@ class ArubaDeviceScanner(object):
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a list containing found device
|
||||
ids. """
|
||||
"""
|
||||
Scans for new devices and return a list containing found device IDs.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
@@ -108,8 +76,10 @@ class ArubaDeviceScanner(object):
|
||||
|
||||
@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. """
|
||||
"""
|
||||
Ensures the information from the Aruba Access Point is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
@@ -122,8 +92,7 @@ class ArubaDeviceScanner(object):
|
||||
return True
|
||||
|
||||
def get_aruba_data(self):
|
||||
""" Retrieve data from Aruba Access Point and return parsed
|
||||
result. """
|
||||
""" Retrieve data from Aruba Access Point and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'User: ')
|
||||
|
@@ -4,32 +4,8 @@ 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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.asuswrt/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
@@ -63,7 +39,7 @@ _IP_NEIGH_REGEX = re.compile(
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a DD-WRT scanner. """
|
||||
""" Validates config and returns an ASUS-WRT scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
@@ -75,7 +51,8 @@ def get_scanner(hass, config):
|
||||
|
||||
|
||||
class AsusWrtDeviceScanner(object):
|
||||
""" This class queries a router running ASUSWRT firmware
|
||||
"""
|
||||
This class queries a router running ASUSWRT firmware
|
||||
for connected devices. Adapted from DD-WRT scanner.
|
||||
"""
|
||||
|
||||
@@ -93,8 +70,9 @@ class AsusWrtDeviceScanner(object):
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
"""
|
||||
Scans for new devices and return a list containing found device IDs.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
@@ -110,8 +88,10 @@ class AsusWrtDeviceScanner(object):
|
||||
|
||||
@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. """
|
||||
"""
|
||||
Ensures the information from the ASUSWRT router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
@@ -129,7 +109,7 @@ class AsusWrtDeviceScanner(object):
|
||||
return True
|
||||
|
||||
def get_asuswrt_data(self):
|
||||
""" Retrieve data from ASUSWRT and return parsed result. """
|
||||
""" Retrieve data from ASUSWRT and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'login: ')
|
||||
@@ -153,15 +133,30 @@ class AsusWrtDeviceScanner(object):
|
||||
devices = {}
|
||||
for lease in leases_result:
|
||||
match = _LEASES_REGEX.search(lease.decode('utf-8'))
|
||||
|
||||
if not match:
|
||||
_LOGGER.warning("Could not parse lease row: %s", lease)
|
||||
continue
|
||||
|
||||
# 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(),
|
||||
'host': match.group('host'),
|
||||
'status': ''
|
||||
}
|
||||
|
||||
for neighbor in neighbors:
|
||||
match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8'))
|
||||
if not match:
|
||||
_LOGGER.warning("Could not parse neighbor row: %s", neighbor)
|
||||
continue
|
||||
if match.group('ip') in devices:
|
||||
devices[match.group('ip')]['status'] = match.group('status')
|
||||
return devices
|
||||
|
@@ -1,34 +1,11 @@
|
||||
"""
|
||||
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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.ddwrt/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
@@ -47,6 +24,7 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}')
|
||||
_MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@@ -64,7 +42,8 @@ def get_scanner(hass, config):
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class DdWrtDeviceScanner(object):
|
||||
""" This class queries a wireless router running DD-WRT firmware
|
||||
"""
|
||||
This class queries a wireless router running DD-WRT firmware
|
||||
for connected devices. Adapted from Tomato scanner.
|
||||
"""
|
||||
|
||||
@@ -77,7 +56,7 @@ class DdWrtDeviceScanner(object):
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
self.mac2name = None
|
||||
self.mac2name = {}
|
||||
|
||||
# Test the router is accessible
|
||||
url = 'http://{}/Status_Wireless.live.asp'.format(self.host)
|
||||
@@ -85,8 +64,9 @@ class DdWrtDeviceScanner(object):
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
@@ -97,35 +77,40 @@ class DdWrtDeviceScanner(object):
|
||||
|
||||
with self.lock:
|
||||
# if not initialised and not already scanned and not found
|
||||
if self.mac2name is None or device not in self.mac2name:
|
||||
if device not in self.mac2name:
|
||||
url = 'http://{}/Status_Lan.live.asp'.format(self.host)
|
||||
data = self.get_ddwrt_data(url)
|
||||
|
||||
if not data:
|
||||
return
|
||||
return None
|
||||
|
||||
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)
|
||||
if not dhcp_leases:
|
||||
return None
|
||||
|
||||
# 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)
|
||||
|
||||
@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. """
|
||||
"""
|
||||
Ensures the information from the DD-WRT router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
@@ -138,32 +123,28 @@ class DdWrtDeviceScanner(object):
|
||||
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!!!
|
||||
self.last_results = []
|
||||
|
||||
# remove leading and trailing single quotes
|
||||
clean_str = active_clients.strip().strip("'")
|
||||
elements = clean_str.split("','")
|
||||
active_clients = data.get('active_wireless', None)
|
||||
if not active_clients:
|
||||
return False
|
||||
|
||||
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])
|
||||
# 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!!!
|
||||
|
||||
return True
|
||||
# remove leading and trailing single quotes
|
||||
clean_str = active_clients.strip().strip("'")
|
||||
elements = clean_str.split("','")
|
||||
|
||||
return False
|
||||
self.last_results.extend(item for item in elements
|
||||
if _MAC_REGEX.match(item))
|
||||
|
||||
return True
|
||||
|
||||
def get_ddwrt_data(self, url):
|
||||
""" Retrieve data from DD-WRT and return parsed result. """
|
||||
""" Retrieve data from DD-WRT and return parsed result. """
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
|
49
homeassistant/components/device_tracker/demo.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
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
|
122
homeassistant/components/device_tracker/fritz.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.fritz
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a FRITZ!Box router for device
|
||||
presence.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.fritz/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns FritzBoxScanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: []},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = FritzBoxScanner(config[DOMAIN])
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class FritzBoxScanner(object):
|
||||
"""
|
||||
This class queries a FRITZ!Box router. It is using the
|
||||
fritzconnection library for communication with the router.
|
||||
|
||||
The API description can be found under:
|
||||
https://pypi.python.org/pypi/fritzconnection/0.4.6
|
||||
|
||||
This scanner retrieves the list of known hosts and checks their
|
||||
corresponding states (on, or off).
|
||||
|
||||
Due to a bug of the fritzbox api (router side) it is not possible
|
||||
to track more than 16 hosts.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
self.last_results = []
|
||||
self.host = '169.254.1.1' # This IP is valid for all fritzboxes
|
||||
self.username = 'admin'
|
||||
self.password = ''
|
||||
self.success_init = True
|
||||
|
||||
# Try to import the fritzconnection library
|
||||
try:
|
||||
# noinspection PyPackageRequirements,PyUnresolvedReferences
|
||||
import fritzconnection as fc
|
||||
except ImportError:
|
||||
_LOGGER.exception("""Failed to import Python library
|
||||
fritzconnection. Please run
|
||||
<home-assistant>/setup to install it.""")
|
||||
self.success_init = False
|
||||
return
|
||||
|
||||
# Check for user specific configuration
|
||||
if CONF_HOST in config.keys():
|
||||
self.host = config[CONF_HOST]
|
||||
if CONF_USERNAME in config.keys():
|
||||
self.username = config[CONF_USERNAME]
|
||||
if CONF_PASSWORD in config.keys():
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
# Establish a connection to the FRITZ!Box
|
||||
try:
|
||||
self.fritz_box = fc.FritzHosts(address=self.host,
|
||||
user=self.username,
|
||||
password=self.password)
|
||||
except (ValueError, TypeError):
|
||||
self.fritz_box = None
|
||||
|
||||
# At this point it is difficult to tell if a connection is established.
|
||||
# So just check for null objects ...
|
||||
if self.fritz_box is None or not self.fritz_box.modelname:
|
||||
self.success_init = False
|
||||
|
||||
if self.success_init:
|
||||
_LOGGER.info("Successfully connected to %s",
|
||||
self.fritz_box.modelname)
|
||||
self._update_info()
|
||||
else:
|
||||
_LOGGER.error("Failed to establish connection to FRITZ!Box "
|
||||
"with IP: %s", self.host)
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scan for new devices and return a list of found device ids. """
|
||||
self._update_info()
|
||||
active_hosts = []
|
||||
for known_host in self.last_results:
|
||||
if known_host["status"] == "1":
|
||||
active_hosts.append(known_host["mac"])
|
||||
return active_hosts
|
||||
|
||||
def get_device_name(self, mac):
|
||||
""" Returns the name of the given device or None if is not known. """
|
||||
ret = self.fritz_box.get_specific_host_entry(mac)["NewHostName"]
|
||||
if ret == {}:
|
||||
return None
|
||||
return ret
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
""" Retrieves latest information from the FRITZ!Box. """
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
_LOGGER.info("Scanning")
|
||||
self.last_results = self.fritz_box.get_hosts_info()
|
||||
return True
|
70
homeassistant/components/device_tracker/geofancy.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.geofancy
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Geofancy platform for the device tracker.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.geofancy/
|
||||
"""
|
||||
from homeassistant.const import (
|
||||
HTTP_UNPROCESSABLE_ENTITY, HTTP_INTERNAL_SERVER_ERROR)
|
||||
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
_SEE = 0
|
||||
|
||||
URL_API_GEOFANCY_ENDPOINT = "/api/geofancy"
|
||||
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
""" Set up an endpoint for the Geofancy app. """
|
||||
|
||||
# Use a global variable to keep setup_scanner compact when using a callback
|
||||
global _SEE
|
||||
_SEE = see
|
||||
|
||||
# POST would be semantically better, but that currently does not work
|
||||
# since Geofancy sends the data as key1=value1&key2=value2
|
||||
# in the request body, while Home Assistant expects json there.
|
||||
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_GEOFANCY_ENDPOINT, _handle_get_api_geofancy)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_api_geofancy(handler, path_match, data):
|
||||
""" Geofancy message received. """
|
||||
|
||||
if not isinstance(data, dict):
|
||||
handler.write_json_message(
|
||||
"Error while parsing Geofancy message.",
|
||||
HTTP_INTERNAL_SERVER_ERROR)
|
||||
return
|
||||
if 'latitude' not in data or 'longitude' not in data:
|
||||
handler.write_json_message(
|
||||
"Location not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
if 'device' not in data or 'id' not in data:
|
||||
handler.write_json_message(
|
||||
"Device id or location id not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
try:
|
||||
gps_coords = (float(data['latitude']), float(data['longitude']))
|
||||
except ValueError:
|
||||
# If invalid latitude / longitude format
|
||||
handler.write_json_message(
|
||||
"Invalid latitude / longitude format.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
# entity id's in Home Assistant must be alphanumerical
|
||||
device_uuid = data['device']
|
||||
device_entity_id = device_uuid.replace('-', '')
|
||||
|
||||
_SEE(dev_id=device_entity_id, gps=gps_coords, location_name=data['id'])
|
||||
|
||||
handler.write_json_message("Geofancy message processed")
|
87
homeassistant/components/device_tracker/icloud.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.icloud
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning iCloud devices.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.icloud/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import re
|
||||
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers.event import track_utc_time_change
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REQUIREMENTS = ['pyicloud==0.7.2']
|
||||
|
||||
CONF_INTERVAL = 'interval'
|
||||
DEFAULT_INTERVAL = 8
|
||||
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
""" Set up the iCloud Scanner. """
|
||||
from pyicloud import PyiCloudService
|
||||
from pyicloud.exceptions import PyiCloudFailedLoginException
|
||||
from pyicloud.exceptions import PyiCloudNoDevicesException
|
||||
|
||||
# Get the username and password from the configuration
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
|
||||
if username is None or password is None:
|
||||
_LOGGER.error('Must specify a username and password')
|
||||
return False
|
||||
|
||||
try:
|
||||
_LOGGER.info('Logging into iCloud Account')
|
||||
# Attempt the login to iCloud
|
||||
api = PyiCloudService(username,
|
||||
password,
|
||||
verify=True)
|
||||
except PyiCloudFailedLoginException as error:
|
||||
_LOGGER.exception('Error logging into iCloud Service: %s', error)
|
||||
return False
|
||||
|
||||
def keep_alive(now):
|
||||
""" Keeps authenticating iCloud connection. """
|
||||
api.authenticate()
|
||||
_LOGGER.info("Authenticate against iCloud")
|
||||
|
||||
track_utc_time_change(hass, keep_alive, second=0)
|
||||
|
||||
def update_icloud(now):
|
||||
""" Authenticate against iCloud and scan for devices. """
|
||||
try:
|
||||
# The session timeouts if we are not using it so we
|
||||
# have to re-authenticate. This will send an email.
|
||||
api.authenticate()
|
||||
# Loop through every device registered with the iCloud account
|
||||
for device in api.devices:
|
||||
status = device.status()
|
||||
location = device.location()
|
||||
# If the device has a location add it. If not do nothing
|
||||
if location:
|
||||
see(
|
||||
dev_id=re.sub(r"(\s|\W|')",
|
||||
'',
|
||||
status['name']),
|
||||
host_name=status['name'],
|
||||
gps=(location['latitude'], location['longitude']),
|
||||
battery=status['batteryLevel']*100,
|
||||
gps_accuracy=location['horizontalAccuracy']
|
||||
)
|
||||
else:
|
||||
# No location found for the device so continue
|
||||
continue
|
||||
except PyiCloudNoDevicesException:
|
||||
_LOGGER.info('No iCloud Devices found!')
|
||||
|
||||
track_utc_time_change(
|
||||
hass, update_icloud,
|
||||
minute=range(0, 60, config.get(CONF_INTERVAL, DEFAULT_INTERVAL)),
|
||||
second=0
|
||||
)
|
||||
|
||||
return True
|
@@ -1,38 +1,11 @@
|
||||
"""
|
||||
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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.luci/
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
@@ -66,7 +39,8 @@ def get_scanner(hass, config):
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class LuciDeviceScanner(object):
|
||||
""" This class queries a wireless router running OpenWrt firmware
|
||||
"""
|
||||
This class queries a wireless router running OpenWrt firmware
|
||||
for connected devices. Adapted from Tomato scanner.
|
||||
|
||||
# opkg install luci-mod-rpc
|
||||
@@ -95,8 +69,9 @@ class LuciDeviceScanner(object):
|
||||
self.success_init = self.token is not None
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
@@ -124,8 +99,10 @@ class LuciDeviceScanner(object):
|
||||
|
||||
@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. """
|
||||
"""
|
||||
Ensures the information from the Luci router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
@@ -179,6 +156,6 @@ def _req_json_rpc(url, method, *args, **kwargs):
|
||||
|
||||
|
||||
def _get_token(host, username, password):
|
||||
""" Get authentication token for the given 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)
|
||||
|
43
homeassistant/components/device_tracker/mqtt.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
MQTT platform for the device tracker.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.mqtt/
|
||||
"""
|
||||
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
|
@@ -1,34 +1,11 @@
|
||||
"""
|
||||
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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.netgear/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
@@ -71,7 +48,6 @@ class NetgearDeviceScanner(object):
|
||||
self.lock = threading.Lock()
|
||||
|
||||
if host is None:
|
||||
print("BIER")
|
||||
self._api = pynetgear.Netgear()
|
||||
elif username is None:
|
||||
self._api = pynetgear.Netgear(password, host)
|
||||
@@ -90,8 +66,9 @@ class NetgearDeviceScanner(object):
|
||||
_LOGGER.error("Failed to Login")
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
self._update_info()
|
||||
|
||||
return (device.mac for device in self.last_results)
|
||||
@@ -106,8 +83,10 @@ class NetgearDeviceScanner(object):
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
""" Retrieves latest information from the Netgear router.
|
||||
Returns boolean if scanning successful. """
|
||||
"""
|
||||
Retrieves latest information from the Netgear router.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return
|
||||
|
||||
|
@@ -1,29 +1,10 @@
|
||||
"""
|
||||
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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.nmap_scanner/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
@@ -45,7 +26,7 @@ _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.1']
|
||||
REQUIREMENTS = ['python-nmap==0.4.3']
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
@@ -74,7 +55,7 @@ def _arp(ip_address):
|
||||
|
||||
|
||||
class NmapDeviceScanner(object):
|
||||
""" This class scans for devices using nmap """
|
||||
""" This class scans for devices using nmap. """
|
||||
|
||||
def __init__(self, config):
|
||||
self.last_results = []
|
||||
@@ -87,8 +68,9 @@ class NmapDeviceScanner(object):
|
||||
_LOGGER.info("nmap scanner initialized")
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
@@ -107,23 +89,28 @@ class NmapDeviceScanner(object):
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
""" Scans the network for devices.
|
||||
Returns boolean if scanning successful. """
|
||||
"""
|
||||
Scans the network for devices.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.info("Scanning")
|
||||
|
||||
from nmap import PortScanner, PortScannerError
|
||||
scanner = PortScanner()
|
||||
|
||||
options = "-sP --host-timeout 5"
|
||||
exclude_targets = set()
|
||||
options = "-F --host-timeout 5s"
|
||||
|
||||
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))
|
||||
boundary = dt_util.now() - self.home_interval
|
||||
last_results = [device for device in self.last_results
|
||||
if device.last_update > boundary]
|
||||
if last_results:
|
||||
# Pylint is confused here.
|
||||
# pylint: disable=no-member
|
||||
options += " --exclude {}".format(",".join(device.ip for device
|
||||
in last_results))
|
||||
else:
|
||||
last_results = []
|
||||
|
||||
try:
|
||||
result = scanner.scan(hosts=self.hosts, arguments=options)
|
||||
@@ -131,18 +118,17 @@ class NmapDeviceScanner(object):
|
||||
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
|
||||
name = info['hostnames'][0]['name'] 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)
|
||||
last_results.append(Device(mac.upper(), name, ipv4, now))
|
||||
|
||||
self.last_results = last_results
|
||||
|
||||
_LOGGER.info("nmap scan successful")
|
||||
return True
|
||||
|
53
homeassistant/components/device_tracker/owntracks.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.owntracks
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
OwnTracks platform for the device tracker.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.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
|
117
homeassistant/components/device_tracker/snmp.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.snmp
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports fetching WiFi associations
|
||||
through SNMP.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.snmp/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import threading
|
||||
import binascii
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
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__)
|
||||
REQUIREMENTS = ['pysnmp==4.2.5']
|
||||
|
||||
CONF_COMMUNITY = "community"
|
||||
CONF_BASEOID = "baseoid"
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns an snmp scanner """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_COMMUNITY, CONF_BASEOID]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = SnmpScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class SnmpScanner(object):
|
||||
"""
|
||||
This class queries any SNMP capable Acces Point for connected devices.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
from pysnmp.entity.rfc3413.oneliner import cmdgen
|
||||
self.snmp = cmdgen.CommandGenerator()
|
||||
|
||||
self.host = cmdgen.UdpTransportTarget((config[CONF_HOST], 161))
|
||||
self.community = cmdgen.CommunityData(config[CONF_COMMUNITY])
|
||||
self.baseoid = cmdgen.MibVariable(config[CONF_BASEOID])
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = []
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_snmp_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]
|
||||
|
||||
# Supressing no-self-use warning
|
||||
# pylint: disable=R0201
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
# We have no names
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the WAP is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
data = self.get_snmp_data()
|
||||
if not data:
|
||||
return False
|
||||
|
||||
self.last_results = data
|
||||
return True
|
||||
|
||||
def get_snmp_data(self):
|
||||
""" Fetch mac addresses from WAP via SNMP. """
|
||||
|
||||
devices = []
|
||||
|
||||
errindication, errstatus, errindex, restable = self.snmp.nextCmd(
|
||||
self.community, self.host, self.baseoid)
|
||||
|
||||
if errindication:
|
||||
_LOGGER.error("SNMPLIB error: %s", errindication)
|
||||
return
|
||||
if errstatus:
|
||||
_LOGGER.error('SNMP error: %s at %s', errstatus.prettyPrint(),
|
||||
errindex and restable[-1][int(errindex)-1]
|
||||
or '?')
|
||||
return
|
||||
|
||||
for resrow in restable:
|
||||
for _, val in resrow:
|
||||
mac = binascii.hexlify(val.asOctets()).decode('utf-8')
|
||||
mac = ':'.join([mac[i:i+2] for i in range(0, len(mac), 2)])
|
||||
devices.append({'mac': mac})
|
||||
return devices
|
@@ -4,32 +4,8 @@ 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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.thomson/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
@@ -71,7 +47,8 @@ def get_scanner(hass, config):
|
||||
|
||||
|
||||
class ThomsonDeviceScanner(object):
|
||||
""" This class queries a router running THOMSON firmware
|
||||
"""
|
||||
This class queries a router running THOMSON firmware
|
||||
for connected devices. Adapted from ASUSWRT scanner.
|
||||
"""
|
||||
|
||||
@@ -107,8 +84,10 @@ class ThomsonDeviceScanner(object):
|
||||
|
||||
@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. """
|
||||
"""
|
||||
Ensures the information from the THOMSON router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
@@ -125,7 +104,7 @@ class ThomsonDeviceScanner(object):
|
||||
return True
|
||||
|
||||
def get_thomson_data(self):
|
||||
""" Retrieve data from THOMSON and return parsed result. """
|
||||
""" Retrieve data from THOMSON and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'Username : ')
|
||||
|
@@ -1,40 +1,11 @@
|
||||
"""
|
||||
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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.tomato/
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
|
@@ -1,35 +1,11 @@
|
||||
"""
|
||||
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 config/configuration.yaml
|
||||
|
||||
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.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.tplink/
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
@@ -56,16 +32,20 @@ def get_scanner(hass, config):
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = Tplink2DeviceScanner(config[DOMAIN])
|
||||
scanner = Tplink3DeviceScanner(config[DOMAIN])
|
||||
|
||||
if not scanner.success_init:
|
||||
scanner = TplinkDeviceScanner(config[DOMAIN])
|
||||
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
|
||||
"""
|
||||
This class queries a wireless router running TP-Link firmware
|
||||
for connected devices.
|
||||
"""
|
||||
|
||||
@@ -85,8 +65,9 @@ class TplinkDeviceScanner(object):
|
||||
self.success_init = self._update_info()
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
@@ -94,15 +75,18 @@ class TplinkDeviceScanner(object):
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def get_device_name(self, device):
|
||||
""" The TP-Link firmware doesn't save the name of the wireless
|
||||
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. """
|
||||
"""
|
||||
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...")
|
||||
@@ -122,33 +106,38 @@ class TplinkDeviceScanner(object):
|
||||
|
||||
|
||||
class Tplink2DeviceScanner(TplinkDeviceScanner):
|
||||
""" This class queries a wireless router running newer version of TP-Link
|
||||
"""
|
||||
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. """
|
||||
"""
|
||||
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. """
|
||||
"""
|
||||
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. """
|
||||
"""
|
||||
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'\
|
||||
url = 'http://{}/data/map_access_wireless_client_grid.json' \
|
||||
.format(self.host)
|
||||
referer = 'http://{}'.format(self.host)
|
||||
|
||||
@@ -158,7 +147,7 @@ class Tplink2DeviceScanner(TplinkDeviceScanner):
|
||||
b64_encoded_username_password = base64.b64encode(
|
||||
username_password.encode('ascii')
|
||||
).decode('ascii')
|
||||
cookie = 'Authorization=Basic {}'\
|
||||
cookie = 'Authorization=Basic {}' \
|
||||
.format(b64_encoded_username_password)
|
||||
|
||||
response = requests.post(url, headers={'referer': referer,
|
||||
@@ -175,7 +164,119 @@ class Tplink2DeviceScanner(TplinkDeviceScanner):
|
||||
self.last_results = {
|
||||
device['mac_addr'].replace('-', ':'): device['name']
|
||||
for device in result
|
||||
}
|
||||
}
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Tplink3DeviceScanner(TplinkDeviceScanner):
|
||||
"""
|
||||
This class queries the Archer C9 router running version 150811 or higher
|
||||
of TP-Link firmware for connected devices.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.stok = ''
|
||||
self.sysauth = ''
|
||||
super(Tplink3DeviceScanner, self).__init__(config)
|
||||
|
||||
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.
|
||||
We are forced to use the MAC address as name here.
|
||||
"""
|
||||
|
||||
return self.last_results.get(device)
|
||||
|
||||
def _get_auth_tokens(self):
|
||||
"""
|
||||
Retrieves auth tokens from the router.
|
||||
"""
|
||||
|
||||
_LOGGER.info("Retrieving auth tokens...")
|
||||
|
||||
url = 'http://{}/cgi-bin/luci/;stok=/login?form=login' \
|
||||
.format(self.host)
|
||||
referer = 'http://{}/webpages/login.html'.format(self.host)
|
||||
|
||||
# if possible implement rsa encryption of password here
|
||||
|
||||
response = requests.post(url,
|
||||
params={'operation': 'login',
|
||||
'username': self.username,
|
||||
'password': self.password},
|
||||
headers={'referer': referer})
|
||||
|
||||
try:
|
||||
self.stok = response.json().get('data').get('stok')
|
||||
_LOGGER.info(self.stok)
|
||||
regex_result = re.search('sysauth=(.*);',
|
||||
response.headers['set-cookie'])
|
||||
self.sysauth = regex_result.group(1)
|
||||
_LOGGER.info(self.sysauth)
|
||||
return True
|
||||
except ValueError:
|
||||
_LOGGER.error("Couldn't fetch auth tokens!")
|
||||
return False
|
||||
|
||||
@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:
|
||||
if (self.stok == '') or (self.sysauth == ''):
|
||||
self._get_auth_tokens()
|
||||
|
||||
_LOGGER.info("Loading wireless clients...")
|
||||
|
||||
url = 'http://{}/cgi-bin/luci/;stok={}/admin/wireless?form=statistics' \
|
||||
.format(self.host, self.stok)
|
||||
referer = 'http://{}/webpages/index.html'.format(self.host)
|
||||
|
||||
response = requests.post(url,
|
||||
params={'operation': 'load'},
|
||||
headers={'referer': referer},
|
||||
cookies={'sysauth': self.sysauth})
|
||||
|
||||
try:
|
||||
json_response = response.json()
|
||||
|
||||
if json_response.get('success'):
|
||||
result = response.json().get('data')
|
||||
else:
|
||||
if json_response.get('errorcode') == 'timeout':
|
||||
_LOGGER.info("Token timed out. "
|
||||
"Relogging on next scan.")
|
||||
self.stok = ''
|
||||
self.sysauth = ''
|
||||
return False
|
||||
else:
|
||||
_LOGGER.error("An unknown error happened "
|
||||
"while fetching data.")
|
||||
return False
|
||||
except ValueError:
|
||||
_LOGGER.error("Router didn't respond with JSON. "
|
||||
"Check if credentials are correct.")
|
||||
return False
|
||||
|
||||
if result:
|
||||
self.last_results = {
|
||||
device['mac'].replace('-', ':'): device['mac']
|
||||
for device in result
|
||||
}
|
||||
return True
|
||||
|
||||
return False
|
||||
|
173
homeassistant/components/device_tracker/ubus.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.ubus
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a OpenWRT router for device
|
||||
presence.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.ubus/
|
||||
"""
|
||||
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 = UbusDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class UbusDeviceScanner(object):
|
||||
"""
|
||||
This class queries a wireless router running OpenWrt firmware
|
||||
for connected devices. Adapted from Tomato scanner.
|
||||
|
||||
Configure your routers' ubus ACL based on following instructions:
|
||||
|
||||
http://wiki.openwrt.org/doc/techref/ubus
|
||||
|
||||
Read only access will be fine.
|
||||
|
||||
To use this class you have to install rpcd-mod-file package
|
||||
in your OpenWrt router:
|
||||
|
||||
opkg install rpcd-mod-file
|
||||
|
||||
"""
|
||||
|
||||
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.url = 'http://{}/ubus'.format(host)
|
||||
|
||||
self.session_id = _get_session_id(self.url, username, password)
|
||||
self.hostapd = []
|
||||
self.leasefile = None
|
||||
self.mac2name = None
|
||||
self.success_init = self.session_id 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.leasefile is None:
|
||||
result = _req_json_rpc(self.url, self.session_id,
|
||||
'call', 'uci', 'get',
|
||||
config="dhcp", type="dnsmasq")
|
||||
if result:
|
||||
values = result["values"].values()
|
||||
self.leasefile = next(iter(values))["leasefile"]
|
||||
else:
|
||||
return
|
||||
|
||||
if self.mac2name is None:
|
||||
result = _req_json_rpc(self.url, self.session_id,
|
||||
'call', 'file', 'read',
|
||||
path=self.leasefile)
|
||||
if result:
|
||||
self.mac2name = dict()
|
||||
for line in result["data"].splitlines():
|
||||
hosts = line.split(" ")
|
||||
self.mac2name[hosts[1].upper()] = hosts[3]
|
||||
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")
|
||||
|
||||
if not self.hostapd:
|
||||
hostapd = _req_json_rpc(self.url, self.session_id,
|
||||
'list', 'hostapd.*', '')
|
||||
self.hostapd.extend(hostapd.keys())
|
||||
|
||||
self.last_results = []
|
||||
results = 0
|
||||
for hostapd in self.hostapd:
|
||||
result = _req_json_rpc(self.url, self.session_id,
|
||||
'call', hostapd, 'get_clients')
|
||||
|
||||
if result:
|
||||
results = results + 1
|
||||
self.last_results.extend(result['clients'].keys())
|
||||
|
||||
return bool(results)
|
||||
|
||||
|
||||
def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params):
|
||||
""" Perform one JSON RPC operation. """
|
||||
|
||||
data = json.dumps({"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": rpcmethod,
|
||||
"params": [session_id,
|
||||
subsystem,
|
||||
method,
|
||||
params]})
|
||||
|
||||
try:
|
||||
res = requests.post(url, data=data, timeout=5)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return
|
||||
|
||||
if res.status_code == 200:
|
||||
response = res.json()
|
||||
|
||||
if rpcmethod == "call":
|
||||
return response["result"][1]
|
||||
else:
|
||||
return response["result"]
|
||||
|
||||
|
||||
def _get_session_id(url, username, password):
|
||||
""" Get authentication token for the given host+username+password. """
|
||||
res = _req_json_rpc(url, "00000000000000000000000000000000", 'call',
|
||||
'session', 'login', username=username,
|
||||
password=password)
|
||||
return res["ubus_rpc_session"]
|
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
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.
|
||||
@@ -18,23 +17,24 @@ from homeassistant.const import (
|
||||
ATTR_SERVICE, ATTR_DISCOVERED)
|
||||
|
||||
DOMAIN = "discovery"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['netdisco==0.3']
|
||||
REQUIREMENTS = ['netdisco==0.5.2']
|
||||
|
||||
SCAN_INTERVAL = 300 # seconds
|
||||
|
||||
# Next 3 lines for now a mirror from netdisco.const
|
||||
# Should setup a mapping netdisco.const -> own constants
|
||||
SERVICE_WEMO = 'belkin_wemo'
|
||||
SERVICE_HUE = 'philips_hue'
|
||||
SERVICE_CAST = 'google_cast'
|
||||
SERVICE_NETGEAR = 'netgear_router'
|
||||
SERVICE_SONOS = 'sonos'
|
||||
SERVICE_PLEX = 'plex_mediaserver'
|
||||
|
||||
SERVICE_HANDLERS = {
|
||||
SERVICE_WEMO: "switch",
|
||||
SERVICE_CAST: "media_player",
|
||||
SERVICE_HUE: "light",
|
||||
SERVICE_NETGEAR: 'device_tracker',
|
||||
SERVICE_SONOS: 'media_player',
|
||||
SERVICE_PLEX: 'media_player',
|
||||
}
|
||||
|
||||
|
||||
@@ -79,13 +79,6 @@ def setup(hass, config):
|
||||
if not component:
|
||||
return
|
||||
|
||||
# Hack - fix when device_tracker supports discovery
|
||||
if service == SERVICE_NETGEAR:
|
||||
bootstrap.setup_component(hass, component, {
|
||||
'device_tracker': {'platform': 'netgear'}
|
||||
})
|
||||
return
|
||||
|
||||
# This component cannot be setup.
|
||||
if not bootstrap.setup_component(hass, component, config):
|
||||
return
|
||||
@@ -95,6 +88,7 @@ def setup(hass, config):
|
||||
ATTR_DISCOVERED: info
|
||||
})
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def start_discovery(event):
|
||||
""" Start discovering. """
|
||||
netdisco = DiscoveryService(SCAN_INTERVAL)
|
||||
|
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
homeassistant.components.downloader
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to download files.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/downloader/
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
@@ -13,7 +15,6 @@ from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import sanitize_filename
|
||||
|
||||
DOMAIN = "downloader"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_DOWNLOAD_FILE = "download_file"
|
||||
|
||||
@@ -42,6 +43,10 @@ def setup(hass, config):
|
||||
|
||||
download_path = config[DOMAIN][CONF_DOWNLOAD_DIR]
|
||||
|
||||
# If path is relative, we assume relative to HASS config dir
|
||||
if not os.path.isabs(download_path):
|
||||
download_path = hass.config.path(download_path)
|
||||
|
||||
if not os.path.isdir(download_path):
|
||||
|
||||
logger.error(
|
||||
|
155
homeassistant/components/ecobee.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
homeassistant.components.ecobee
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Ecobee Component
|
||||
|
||||
This component adds support for Ecobee3 Wireless Thermostats.
|
||||
You will need to setup developer access to your thermostat,
|
||||
and create and API key on the ecobee website.
|
||||
|
||||
The first time you run this component you will see a configuration
|
||||
component card in Home Assistant. This card will contain a PIN code
|
||||
that you will need to use to authorize access to your thermostat. You
|
||||
can do this at https://www.ecobee.com/consumerportal/index.html
|
||||
Click My Apps, Add application, Enter Pin and click Authorize.
|
||||
|
||||
After authorizing the application click the button in the configuration
|
||||
card. Now your thermostat and sensors should shown in home-assistant.
|
||||
|
||||
You can use the optional hold_temp parameter to set whether or not holds
|
||||
are set indefintely or until the next scheduled event.
|
||||
|
||||
ecobee:
|
||||
api_key: asdfasdfasdfasdfasdfaasdfasdfasdfasdf
|
||||
hold_temp: True
|
||||
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant import bootstrap
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.const import (
|
||||
EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, ATTR_DISCOVERED, CONF_API_KEY)
|
||||
|
||||
DOMAIN = "ecobee"
|
||||
DISCOVER_THERMOSTAT = "ecobee.thermostat"
|
||||
DISCOVER_SENSORS = "ecobee.sensor"
|
||||
NETWORK = None
|
||||
HOLD_TEMP = 'hold_temp'
|
||||
|
||||
REQUIREMENTS = [
|
||||
'https://github.com/nkgilley/python-ecobee-api/archive/'
|
||||
'92a2f330cbaf601d0618456fdd97e5a8c42c1c47.zip#python-ecobee==0.0.4']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ECOBEE_CONFIG_FILE = 'ecobee.conf'
|
||||
_CONFIGURING = {}
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
|
||||
|
||||
|
||||
def request_configuration(network, hass, config):
|
||||
""" Request configuration steps from the user. """
|
||||
configurator = get_component('configurator')
|
||||
if 'ecobee' in _CONFIGURING:
|
||||
configurator.notify_errors(
|
||||
_CONFIGURING['ecobee'], "Failed to register, please try again.")
|
||||
|
||||
return
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def ecobee_configuration_callback(callback_data):
|
||||
""" Actions to do when our configuration callback is called. """
|
||||
network.request_tokens()
|
||||
network.update()
|
||||
setup_ecobee(hass, network, config)
|
||||
|
||||
_CONFIGURING['ecobee'] = configurator.request_config(
|
||||
hass, "Ecobee", ecobee_configuration_callback,
|
||||
description=(
|
||||
'Please authorize this app at https://www.ecobee.com/consumer'
|
||||
'portal/index.html with pin code: ' + network.pin),
|
||||
description_image="/static/images/config_ecobee_thermostat.png",
|
||||
submit_caption="I have authorized the app."
|
||||
)
|
||||
|
||||
|
||||
def setup_ecobee(hass, network, config):
|
||||
""" Setup ecobee thermostat """
|
||||
# If ecobee has a PIN then it needs to be configured.
|
||||
if network.pin is not None:
|
||||
request_configuration(network, hass, config)
|
||||
return
|
||||
|
||||
if 'ecobee' in _CONFIGURING:
|
||||
configurator = get_component('configurator')
|
||||
configurator.request_done(_CONFIGURING.pop('ecobee'))
|
||||
|
||||
# Ensure component is loaded
|
||||
bootstrap.setup_component(hass, 'thermostat', config)
|
||||
bootstrap.setup_component(hass, 'sensor', config)
|
||||
|
||||
hold_temp = config[DOMAIN].get(HOLD_TEMP, False)
|
||||
|
||||
# Fire thermostat discovery event
|
||||
hass.bus.fire(EVENT_PLATFORM_DISCOVERED, {
|
||||
ATTR_SERVICE: DISCOVER_THERMOSTAT,
|
||||
ATTR_DISCOVERED: {'hold_temp': hold_temp}
|
||||
})
|
||||
|
||||
# Fire sensor discovery event
|
||||
hass.bus.fire(EVENT_PLATFORM_DISCOVERED, {
|
||||
ATTR_SERVICE: DISCOVER_SENSORS,
|
||||
ATTR_DISCOVERED: {}
|
||||
})
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class EcobeeData(object):
|
||||
""" Gets the latest data and update the states. """
|
||||
|
||||
def __init__(self, config_file):
|
||||
from pyecobee import Ecobee
|
||||
self.ecobee = Ecobee(config_file)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
""" Get the latest data from pyecobee. """
|
||||
self.ecobee.update()
|
||||
_LOGGER.info("ecobee data updated successfully.")
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""
|
||||
Setup Ecobee.
|
||||
Will automatically load thermostat and sensor components to support
|
||||
devices discovered on the network.
|
||||
"""
|
||||
# pylint: disable=global-statement, import-error
|
||||
global NETWORK
|
||||
|
||||
if 'ecobee' in _CONFIGURING:
|
||||
return
|
||||
|
||||
from pyecobee import config_from_file
|
||||
|
||||
# Create ecobee.conf if it doesn't exist
|
||||
if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)):
|
||||
if config[DOMAIN].get(CONF_API_KEY) is None:
|
||||
_LOGGER.error("No ecobee api_key found in config.")
|
||||
return
|
||||
jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)}
|
||||
config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig)
|
||||
|
||||
NETWORK = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE))
|
||||
|
||||
setup_ecobee(hass, NETWORK.ecobee, config)
|
||||
|
||||
return True
|
@@ -8,7 +8,7 @@ import re
|
||||
import os
|
||||
import logging
|
||||
|
||||
from . import version
|
||||
from . import version, mdi_version
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import URL_ROOT, HTTP_OK
|
||||
|
||||
@@ -19,10 +19,11 @@ INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template')
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FRONTEND_URLS = [
|
||||
URL_ROOT, '/logbook', '/history', '/devService', '/devState', '/devEvent']
|
||||
STATES_URL = re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)')
|
||||
URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState',
|
||||
'/devEvent', '/devInfo', '/devTemplate', '/states']
|
||||
|
||||
_FINGERPRINT = re.compile(r'^(\w+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
@@ -34,7 +35,8 @@ def setup(hass, config):
|
||||
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)
|
||||
hass.http.register_path('GET', '/service_worker.js',
|
||||
_handle_get_service_worker, False)
|
||||
|
||||
# Static files
|
||||
hass.http.register_path(
|
||||
@@ -43,13 +45,15 @@ def setup(hass, config):
|
||||
hass.http.register_path(
|
||||
'HEAD', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_static, False)
|
||||
hass.http.register_path(
|
||||
'GET', re.compile(r'/local/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_local, False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_root(handler, path_match, data):
|
||||
""" Renders the debug interface. """
|
||||
|
||||
""" Renders the frontend. """
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
handler.end_headers()
|
||||
@@ -60,7 +64,7 @@ def _handle_get_root(handler, path_match, data):
|
||||
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
|
||||
auth = ('no_password_set' if handler.server.api_password is None
|
||||
else data.get('api_password', ''))
|
||||
|
||||
with open(INDEX_PATH) as template_file:
|
||||
@@ -68,18 +72,42 @@ def _handle_get_root(handler, path_match, data):
|
||||
|
||||
template_html = template_html.replace('{{ app_url }}', app_url)
|
||||
template_html = template_html.replace('{{ auth }}', auth)
|
||||
template_html = template_html.replace('{{ icons }}', mdi_version.VERSION)
|
||||
|
||||
handler.wfile.write(template_html.encode("UTF-8"))
|
||||
|
||||
|
||||
def _handle_get_service_worker(handler, path_match, data):
|
||||
""" Returns service worker for the frontend. """
|
||||
if handler.server.development:
|
||||
sw_path = "home-assistant-polymer/build/service_worker.js"
|
||||
else:
|
||||
sw_path = "service_worker.js"
|
||||
|
||||
handler.write_file(os.path.join(os.path.dirname(__file__), 'www_static',
|
||||
sw_path))
|
||||
|
||||
|
||||
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"
|
||||
# Strip md5 hash out
|
||||
fingerprinted = _FINGERPRINT.match(req_file)
|
||||
if fingerprinted:
|
||||
req_file = "{}.{}".format(*fingerprinted.groups())
|
||||
|
||||
path = os.path.join(os.path.dirname(__file__), 'www_static', req_file)
|
||||
|
||||
handler.write_file(path)
|
||||
|
||||
|
||||
def _handle_get_local(handler, path_match, data):
|
||||
"""
|
||||
Returns a static file from the hass.config.path/www for the frontend.
|
||||
"""
|
||||
req_file = util.sanitize_path(path_match.group('file'))
|
||||
|
||||
path = handler.server.hass.config.path('www', req_file)
|
||||
|
||||
handler.write_file(path)
|
||||
|
@@ -1,22 +1,19 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<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='manifest' href='/static/manifest.json'>
|
||||
<link rel='icon' href='/static/favicon.ico'>
|
||||
<link rel='apple-touch-icon' sizes='180x180'
|
||||
href='/static/favicon-apple-180x180.png'>
|
||||
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='viewport' content='width=device-width, user-scalable=no'>
|
||||
<meta name='theme-color' content='#03a9f4'>
|
||||
<style>
|
||||
#init {
|
||||
#ha-init-skeleton {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
@@ -26,26 +23,29 @@
|
||||
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;
|
||||
margin-bottom: 123px;
|
||||
}
|
||||
</style>
|
||||
<link rel='import' href='/static/{{ app_url }}' async>
|
||||
</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>
|
||||
<div id='ha-init-skeleton'><img src='/static/favicon-192x192.png' height='192'></div>
|
||||
<script>
|
||||
var webComponentsSupported = ('registerElement' in document &&
|
||||
'import' in document.createElement('link') &&
|
||||
'content' in document.createElement('template'))
|
||||
if (!webComponentsSupported) {
|
||||
var script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = '/static/webcomponents-lite.min.js'
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
</script>
|
||||
<home-assistant auth='{{ auth }}' icons='{{ icons }}'></home-assistant>
|
||||
</body>
|
||||
</html>
|
||||
|
2
homeassistant/components/frontend/mdi_version.py
Normal file
@@ -0,0 +1,2 @@
|
||||
""" DO NOT MODIFY. Auto-generated by update_mdi script """
|
||||
VERSION = "7d76081c37634d36af21f5cc1ca79408"
|
@@ -1,2 +1,2 @@
|
||||
""" DO NOT MODIFY. Auto-generated by build_frontend script """
|
||||
VERSION = "35ecb5457a9ff0f4142c2605b53eb843"
|
||||
VERSION = "be08c5a3ce12040bbdba2db83cb1a568"
|
||||
|
BIN
homeassistant/components/frontend/www_static/favicon-384x384.png
Normal file
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 18 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 |
@@ -3,12 +3,17 @@
|
||||
"short_name": "Assistant",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#03A9F4",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/static\/favicon-192x192.png",
|
||||
"src": "/static/favicon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
"type": "image/png",
|
||||
},
|
||||
{
|
||||
"src": "/static/favicon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
1
homeassistant/components/frontend/www_static/mdi.html
Normal file
@@ -0,0 +1,5 @@
|
||||
!function(e){function t(r){if(n[r])return n[r].exports;var s=n[r]={exports:{},id:r,loaded:!1};return e[r].call(s.exports,s,s.exports,t),s.loaded=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(0)}([/*!*************************************!*\
|
||||
!*** ./src/service-worker/index.js ***!
|
||||
\*************************************/
|
||||
function(e,t,n){"use strict";var r="0.10",s="/",c=["/","/logbook","/history","/map","/devService","/devState","/devEvent","/devInfo","/states"],i=["/static/favicon-192x192.png"];self.addEventListener("install",function(e){e.waitUntil(caches.open(r).then(function(e){return e.addAll(i.concat(s))}))}),self.addEventListener("activate",function(e){}),self.addEventListener("message",function(e){}),self.addEventListener("fetch",function(e){var t=e.request.url.substr(e.request.url.indexOf("/",8));i.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(e.request)})),c.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(s).then(function(n){var r=fetch(e.request).then(function(e){return t.put(s,e.clone()),e});return n||r})}))})}]);
|
||||
//# sourceMappingURL=service_worker.js.map
|
Before Width: | Height: | Size: 51 KiB |
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
homeassistant.components.group
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to group devices that can be turned on or off.
|
||||
"""
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/group/
|
||||
"""
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.helpers import generate_entity_id
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
@@ -12,17 +13,18 @@ from homeassistant.helpers.entity import Entity
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, STATE_ON, STATE_OFF,
|
||||
STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN)
|
||||
STATE_HOME, STATE_NOT_HOME, STATE_OPEN, STATE_CLOSED,
|
||||
STATE_UNKNOWN)
|
||||
|
||||
DOMAIN = "group"
|
||||
DEPENDENCIES = []
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
ATTR_AUTO = "auto"
|
||||
|
||||
# List of ON/OFF state tuples for groupable states
|
||||
_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME)]
|
||||
_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME),
|
||||
(STATE_OPEN, STATE_CLOSED)]
|
||||
|
||||
|
||||
def _get_group_on_off(state):
|
||||
|
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
homeassistant.components.history
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provide pre-made queries on top of the recorder component.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/history/
|
||||
"""
|
||||
import re
|
||||
from datetime import timedelta
|
||||
@@ -147,8 +149,6 @@ def _api_history_period(handler, path_match, data):
|
||||
|
||||
end_time = start_time + one_day
|
||||
|
||||
print("Fetchign", start_time, end_time)
|
||||
|
||||
entity_id = data.get('filter_entity_id')
|
||||
|
||||
handler.write_json(
|
||||
|
@@ -1,89 +1,22 @@
|
||||
"""
|
||||
homeassistant.components.httpinterface
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
homeassistant.components.http
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
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."
|
||||
}
|
||||
|
||||
For more details about the RESTful API, please refer to the documentation at
|
||||
https://home-assistant.io/developers/api/
|
||||
"""
|
||||
|
||||
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
|
||||
import gzip
|
||||
from http import cookies
|
||||
from http.server import SimpleHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from socketserver import ThreadingMixIn
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import homeassistant.core as ha
|
||||
@@ -99,51 +32,43 @@ 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"
|
||||
CONF_SSL_CERTIFICATE = 'ssl_certificate'
|
||||
CONF_SSL_KEY = 'ssl_key'
|
||||
|
||||
DATA_API_PASSWORD = 'api_password'
|
||||
|
||||
# Throttling time in seconds for expired sessions check
|
||||
MIN_SEC_SESSION_CLEARING = timedelta(seconds=20)
|
||||
SESSION_CLEAR_INTERVAL = timedelta(seconds=20)
|
||||
SESSION_TIMEOUT_SECONDS = 1800
|
||||
SESSION_KEY = 'sessionId'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config=None):
|
||||
def setup(hass, config):
|
||||
""" Sets up the HTTP API and debug interface. """
|
||||
if config is None or DOMAIN not in config:
|
||||
config = {DOMAIN: {}}
|
||||
conf = config.get(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()
|
||||
api_password = util.convert(conf.get(CONF_API_PASSWORD), str)
|
||||
|
||||
# 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)
|
||||
server_host = conf.get(CONF_SERVER_HOST, '0.0.0.0')
|
||||
server_port = conf.get(CONF_SERVER_PORT, SERVER_PORT)
|
||||
development = str(conf.get(CONF_DEVELOPMENT, "")) == "1"
|
||||
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
|
||||
ssl_key = conf.get(CONF_SSL_KEY)
|
||||
|
||||
try:
|
||||
server = HomeAssistantHTTPServer(
|
||||
(server_host, server_port), RequestHandler, hass, api_password,
|
||||
development, no_password_set, sessions_enabled)
|
||||
development, ssl_certificate, ssl_key)
|
||||
except OSError:
|
||||
# Happens if address already in use
|
||||
# If address already in use
|
||||
_LOGGER.exception("Error setting up HTTP server")
|
||||
return False
|
||||
|
||||
@@ -153,7 +78,8 @@ def setup(hass, config=None):
|
||||
threading.Thread(target=server.start, daemon=True).start())
|
||||
|
||||
hass.http = server
|
||||
hass.config.api = rem.API(util.get_local_ip(), api_password, server_port)
|
||||
hass.config.api = rem.API(util.get_local_ip(), api_password, server_port,
|
||||
ssl_certificate is not None)
|
||||
|
||||
return True
|
||||
|
||||
@@ -168,17 +94,16 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, server_address, request_handler_class,
|
||||
hass, api_password, development, no_password_set,
|
||||
sessions_enabled):
|
||||
hass, api_password, development, ssl_certificate, ssl_key):
|
||||
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)
|
||||
self.sessions = SessionStore()
|
||||
self.use_ssl = ssl_certificate is not None
|
||||
|
||||
# We will lazy init this one if needed
|
||||
self.event_forwarder = None
|
||||
@@ -186,6 +111,12 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
if development:
|
||||
_LOGGER.info("running http in development mode")
|
||||
|
||||
if ssl_certificate is not None:
|
||||
wrap_kwargs = {'certfile': ssl_certificate}
|
||||
if ssl_key is not None:
|
||||
wrap_kwargs['keyfile'] = ssl_key
|
||||
self.socket = ssl.wrap_socket(self.socket, **wrap_kwargs)
|
||||
|
||||
def start(self):
|
||||
""" Starts the HTTP server. """
|
||||
def stop_http(event):
|
||||
@@ -194,8 +125,11 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
|
||||
self.hass.bus.listen_once(ha.EVENT_HOMEASSISTANT_STOP, stop_http)
|
||||
|
||||
protocol = 'https' if self.use_ssl else 'http'
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting web interface at http://%s:%d", *self.server_address)
|
||||
"Starting web interface at %s://%s:%d",
|
||||
protocol, self.server_address[0], self.server_address[1])
|
||||
|
||||
# 31-1-2015: Refactored frontend/api components out of this component
|
||||
# To prevent stuff from breaking, load the two extracted components
|
||||
@@ -205,7 +139,7 @@ class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
self.serve_forever()
|
||||
|
||||
def register_path(self, method, url, callback, require_auth=True):
|
||||
""" Registers a path wit the server. """
|
||||
""" Registers a path with the server. """
|
||||
self.paths.append((method, url, callback, require_auth))
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
@@ -227,23 +161,25 @@ class RequestHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
def __init__(self, req, client_addr, server):
|
||||
""" Contructor, call the base constructor and set up session """
|
||||
self._session = None
|
||||
# Track if this was an authenticated request
|
||||
self.authenticated = False
|
||||
SimpleHTTPRequestHandler.__init__(self, req, client_addr, server)
|
||||
|
||||
def log_message(self, fmt, *arguments):
|
||||
""" Redirect built-in log to HA logging """
|
||||
_LOGGER.info(fmt, *arguments)
|
||||
if self.server.api_password is None:
|
||||
_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]
|
||||
# Read query input. parse_qs gives a list for each value, we want last
|
||||
data = {key: data[-1] for key, data in parse_qs(url.query).items()}
|
||||
|
||||
# Did we get post input ?
|
||||
content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0))
|
||||
@@ -262,18 +198,12 @@ class RequestHandler(SimpleHTTPRequestHandler):
|
||||
"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)
|
||||
self.authenticated = (self.server.api_password is None
|
||||
or self.headers.get(HTTP_HEADER_HA_AUTH) ==
|
||||
self.server.api_password
|
||||
or data.get(DATA_API_PASSWORD) ==
|
||||
self.server.api_password
|
||||
or self.verify_session())
|
||||
|
||||
if '_METHOD' in data:
|
||||
method = data.pop('_METHOD')
|
||||
@@ -306,18 +236,13 @@ class RequestHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
# 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:
|
||||
if require_auth and not self.authenticated:
|
||||
self.write_json_message(
|
||||
"API password missing or incorrect.", HTTP_UNAUTHORIZED)
|
||||
return
|
||||
|
||||
else:
|
||||
if self._session is None and require_auth:
|
||||
self._session = self.server.sessions.create(
|
||||
api_password)
|
||||
|
||||
handle_request_method(self, path_match, data)
|
||||
handle_request_method(self, path_match, data)
|
||||
|
||||
elif path_matched_but_not_method:
|
||||
self.send_response(HTTP_METHOD_NOT_ALLOWED)
|
||||
@@ -368,18 +293,19 @@ class RequestHandler(SimpleHTTPRequestHandler):
|
||||
json.dumps(data, indent=4, sort_keys=True,
|
||||
cls=rem.JSONEncoder).encode("UTF-8"))
|
||||
|
||||
def write_file(self, path):
|
||||
def write_file(self, path, cache_headers=True):
|
||||
""" Returns a file to the user. """
|
||||
try:
|
||||
with open(path, 'rb') as inp:
|
||||
self.write_file_pointer(self.guess_type(path), inp)
|
||||
self.write_file_pointer(self.guess_type(path), inp,
|
||||
cache_headers)
|
||||
|
||||
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):
|
||||
def write_file_pointer(self, content_type, inp, cache_headers=True):
|
||||
"""
|
||||
Helper function to write a file pointer to the user.
|
||||
Does not do error handling.
|
||||
@@ -389,7 +315,8 @@ class RequestHandler(SimpleHTTPRequestHandler):
|
||||
self.send_response(HTTP_OK)
|
||||
self.send_header(HTTP_HEADER_CONTENT_TYPE, content_type)
|
||||
|
||||
self.set_cache_header()
|
||||
if cache_headers:
|
||||
self.set_cache_header()
|
||||
self.set_session_cookie_header()
|
||||
|
||||
if do_gzip:
|
||||
@@ -416,123 +343,126 @@ class RequestHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
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
|
||||
if self.server.development:
|
||||
return
|
||||
|
||||
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))
|
||||
# 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:
|
||||
""" Add the header for the session cookie and return session id. """
|
||||
if not self.authenticated:
|
||||
return None
|
||||
|
||||
session_id = self.get_current_session_id()
|
||||
session_id = self.get_cookie_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
|
||||
self.server.sessions.extend_validation(session_id)
|
||||
return session_id
|
||||
|
||||
return None
|
||||
self.send_header(
|
||||
'Set-Cookie',
|
||||
'{}={}'.format(SESSION_KEY, self.server.sessions.create())
|
||||
)
|
||||
|
||||
def get_current_session_id(self):
|
||||
return session_id
|
||||
|
||||
def verify_session(self):
|
||||
""" Verify that we are in a valid session. """
|
||||
return self.get_cookie_session_id() is not None
|
||||
|
||||
def get_cookie_session_id(self):
|
||||
"""
|
||||
Extracts the current session id from the
|
||||
cookie or returns None if not set
|
||||
cookie or returns None if not set or invalid
|
||||
"""
|
||||
if 'Cookie' not in self.headers:
|
||||
return None
|
||||
|
||||
cookie = cookies.SimpleCookie()
|
||||
try:
|
||||
cookie.load(self.headers["Cookie"])
|
||||
except cookies.CookieError:
|
||||
return None
|
||||
|
||||
if self.headers.get('Cookie', None) is not None:
|
||||
cookie.load(self.headers.get("Cookie"))
|
||||
morsel = cookie.get(SESSION_KEY)
|
||||
|
||||
if cookie.get(SESSION_KEY, False):
|
||||
return cookie[SESSION_KEY].value
|
||||
if morsel is None:
|
||||
return None
|
||||
|
||||
session_id = cookie[SESSION_KEY].value
|
||||
|
||||
if self.server.sessions.is_valid(session_id):
|
||||
return session_id
|
||||
|
||||
return None
|
||||
|
||||
def destroy_session(self):
|
||||
""" Destroys session. """
|
||||
session_id = self.get_cookie_session_id()
|
||||
|
||||
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
|
||||
if session_id is None:
|
||||
return
|
||||
|
||||
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()
|
||||
self.send_header('Set-Cookie', '')
|
||||
self.server.sessions.destroy(session_id)
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def session_valid_time():
|
||||
""" Time till when a session will be valid. """
|
||||
return date_util.utcnow() + timedelta(seconds=SESSION_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
class SessionStore(object):
|
||||
""" Responsible for storing and retrieving http sessions """
|
||||
def __init__(self, enabled=True):
|
||||
def __init__(self):
|
||||
""" Set up the session store """
|
||||
self._sessions = {}
|
||||
self.enabled = enabled
|
||||
self.session_lock = threading.RLock()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@Throttle(MIN_SEC_SESSION_CLEARING)
|
||||
def remove_expired(self):
|
||||
@util.Throttle(SESSION_CLEAR_INTERVAL)
|
||||
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)
|
||||
now = date_util.utcnow()
|
||||
for key in [key for key, valid_time in self._sessions.items()
|
||||
if valid_time < now]:
|
||||
self._sessions.pop(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 is_valid(self, key):
|
||||
""" Return True if a valid session is given. """
|
||||
with self._lock:
|
||||
self._remove_expired()
|
||||
|
||||
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
|
||||
return (key in self._sessions and
|
||||
self._sessions[key] > date_util.utcnow())
|
||||
|
||||
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 extend_validation(self, key):
|
||||
""" Extend a session validation time. """
|
||||
with self._lock:
|
||||
if key not in self._sessions:
|
||||
return
|
||||
self._sessions[key] = session_valid_time()
|
||||
|
||||
def create(self, api_password):
|
||||
""" Creates a new session and adds it to the sessions """
|
||||
if self.enabled is not True:
|
||||
return None
|
||||
def destroy(self, key):
|
||||
""" Destroy a session by key. """
|
||||
with self._lock:
|
||||
self._sessions.pop(key, 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
|
||||
def create(self):
|
||||
""" Creates a new session. """
|
||||
with self._lock:
|
||||
session_id = util.get_random_string(20)
|
||||
|
||||
while session_id in self._sessions:
|
||||
session_id = util.get_random_string(20)
|
||||
|
||||
self._sessions[session_id] = session_valid_time()
|
||||
|
||||
return session_id
|
||||
|
64
homeassistant/components/ifttt.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
homeassistant.components.ifttt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
This component enable you to trigger Maker IFTTT recipes.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/ifttt/
|
||||
"""
|
||||
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'
|
||||
|
||||
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
|
104
homeassistant/components/influxdb.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
homeassistant.components.influxdb
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
InfluxDB component which allows you to send data to an Influx database.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/influxdb/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import homeassistant.util as util
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.const import (EVENT_STATE_CHANGED, STATE_ON, STATE_OFF,
|
||||
STATE_UNLOCKED, STATE_LOCKED, STATE_UNKNOWN)
|
||||
from homeassistant.components.sun import (STATE_ABOVE_HORIZON,
|
||||
STATE_BELOW_HORIZON)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "influxdb"
|
||||
DEPENDENCIES = []
|
||||
|
||||
DEFAULT_HOST = 'localhost'
|
||||
DEFAULT_PORT = 8086
|
||||
DEFAULT_DATABASE = 'home_assistant'
|
||||
|
||||
REQUIREMENTS = ['influxdb==2.10.0']
|
||||
|
||||
CONF_HOST = 'host'
|
||||
CONF_PORT = 'port'
|
||||
CONF_DB_NAME = 'database'
|
||||
CONF_USERNAME = 'username'
|
||||
CONF_PASSWORD = 'password'
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup the InfluxDB component. """
|
||||
|
||||
from influxdb import InfluxDBClient, exceptions
|
||||
|
||||
if not validate_config(config, {DOMAIN: ['host']}, _LOGGER):
|
||||
return False
|
||||
|
||||
conf = config[DOMAIN]
|
||||
|
||||
host = conf[CONF_HOST]
|
||||
port = util.convert(conf.get(CONF_PORT), int, DEFAULT_PORT)
|
||||
database = util.convert(conf.get(CONF_DB_NAME), str, DEFAULT_DATABASE)
|
||||
username = util.convert(conf.get(CONF_USERNAME), str)
|
||||
password = util.convert(conf.get(CONF_PASSWORD), str)
|
||||
|
||||
try:
|
||||
influx = InfluxDBClient(host=host, port=port, username=username,
|
||||
password=password, database=database)
|
||||
databases = [i['name'] for i in influx.get_list_database()]
|
||||
except exceptions.InfluxDBClientError:
|
||||
_LOGGER.error("Database host is not accessible. "
|
||||
"Please check your entries in the configuration file.")
|
||||
return False
|
||||
|
||||
if database not in databases:
|
||||
_LOGGER.error("Database %s doesn't exist", database)
|
||||
return False
|
||||
|
||||
def influx_event_listener(event):
|
||||
""" Listen for new messages on the bus and sends them to Influx. """
|
||||
|
||||
state = event.data.get('new_state')
|
||||
|
||||
if state is None:
|
||||
return
|
||||
|
||||
if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON):
|
||||
_state = 1
|
||||
elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN,
|
||||
STATE_BELOW_HORIZON):
|
||||
_state = 0
|
||||
else:
|
||||
_state = state.state
|
||||
|
||||
measurement = state.attributes.get('unit_of_measurement', state.domain)
|
||||
|
||||
json_body = [
|
||||
{
|
||||
'measurement': measurement,
|
||||
'tags': {
|
||||
'domain': state.domain,
|
||||
'entity_id': state.object_id,
|
||||
},
|
||||
'time': event.time_fired,
|
||||
'fields': {
|
||||
'value': _state,
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
influx.write_points(json_body)
|
||||
except exceptions.InfluxDBClientError:
|
||||
_LOGGER.exception('Error saving event to InfluxDB')
|
||||
|
||||
hass.bus.listen(EVENT_STATE_CHANGED, influx_event_listener)
|
||||
|
||||
return True
|