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

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

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

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

|
||||
|
||||
For example to control the lights there are two components. One is the device_tracker that polls the wireless router for connected devices and updates the state of the tracked devices in the State Machine to be either 'Home' or 'Not Home'.
|
||||
|
||||
When a state is changed a state_changed event is fired for which the device_sun_light_trigger component is listening. Based on the new state of the device combined with the state of the sun it will decide if it should turn the lights on or off:
|
||||
|
||||
In the event that the state of device 'Paulus Nexus 5' changes to the 'Home' state:
|
||||
If the sun has set and the lights are not on:
|
||||
Turn on the lights
|
||||
|
||||
In the event that the combined state of all tracked devices changes to 'Not Home':
|
||||
If the lights are on:
|
||||
Turn off the lights
|
||||
|
||||
In the event of the sun setting:
|
||||
If the lights are off and the combined state of all tracked device equals 'Home':
|
||||
Turn on the lights
|
||||
|
||||
By using the Bus as a central communication hub between components it is easy to replace components or add functionality. For example if you would want to change the way devices are detected you only have to write a component that updates the device states in the State Machine.
|
||||
|
||||
### Components
|
||||
|
||||
**sun**
|
||||
Tracks the state of the sun and when the next sun rising and setting will occur.
|
||||
Depends on: latitude and longitude
|
||||
Action: maintains state of `weather.sun` including attributes `next_rising` and `next_setting`
|
||||
|
||||
**device_tracker**
|
||||
Keeps track of which devices are currently home.
|
||||
Action: sets the state per device and maintains a combined state called `all_devices`. Keeps track of known devices in the file `known_devices.csv`.
|
||||
|
||||
**Light**
|
||||
Keeps track which lights are turned on and can control the lights.
|
||||
|
||||
**WeMo**
|
||||
Keeps track which WeMo switches are in the network and allows you to control them.
|
||||
|
||||
**device_sun_light_trigger**
|
||||
Turns lights on or off using a light control component based on state of the sun and devices that are home.
|
||||
Depends on: light control, track_sun, DeviceTracker
|
||||
Action:
|
||||
* Turns lights off when all devices leave home.
|
||||
* Turns lights on when a device is home while sun is setting.
|
||||
* Turns lights on when a device gets home after sun set.
|
||||
|
||||
Registers services `light_control/turn_light_on` and `light_control/turn_light_off` to turn a or all lights on or off.
|
||||
|
||||
Optional service data:
|
||||
- `light_id` - only act on specific light. Else targets all.
|
||||
- `transition_seconds` - seconds to take to swithc to new state.
|
||||
|
||||
**chromecast**
|
||||
Registers three services to start playing YouTube video's on the ChromeCast.
|
||||
|
||||
Service `chromecast/play_youtube_video` starts playing the specified video on the YouTube app on the ChromeCast. Specify video using `video` in service_data.
|
||||
|
||||
Service `chromecast/start_fireplace` will start a YouTube movie simulating a fireplace and the `chromecast/start_epic_sax` service will start playing Epic Sax Guy 10h version.
|
||||
|
||||
**media_buttons**
|
||||
Registers services that will simulate key presses on the keyboard. It currently offers the following Buttons as a Service (BaaS): `keyboard/volume_up`, `keyboard/volume_down` and `keyboard/media_play_pause`
|
||||
This actor depends on: PyUserInput
|
||||
|
||||
**downloader**
|
||||
Registers service `downloader/download_file` that will download files. File to download is specified in the `url` field in the service data.
|
||||
|
||||
**browser**
|
||||
Registers service `browser/browse_url` that opens `url` as specified in event_data in the system default browser.
|
||||
If you run into issues while using Home Assistant or during development of a component, check the [Home Assistant help section](https://home-assistant.io/help/) how to reach us.
|
||||
|
||||
@@ -1,871 +0,0 @@
|
||||
<TaskerData sr="" dvi="1" tv="4.2u3m">
|
||||
<dmetric>1080.0,1776.0</dmetric>
|
||||
<Profile sr="prof25" ve="2">
|
||||
<cdate>1380613730755</cdate>
|
||||
<clp>true</clp>
|
||||
<edate>1382769497429</edate>
|
||||
<id>25</id>
|
||||
<mid0>23</mid0>
|
||||
<mid1>20</mid1>
|
||||
<nme>HA Power USB</nme>
|
||||
<pri>10</pri>
|
||||
<State sr="con0">
|
||||
<code>10</code>
|
||||
<Int sr="arg0" val="2"/>
|
||||
</State>
|
||||
</Profile>
|
||||
<Profile sr="prof26" ve="2">
|
||||
<cdate>1380613730755</cdate>
|
||||
<clp>true</clp>
|
||||
<edate>1383003483161</edate>
|
||||
<id>26</id>
|
||||
<mid0>22</mid0>
|
||||
<mid1>20</mid1>
|
||||
<nme>HA Power Wireless</nme>
|
||||
<pri>10</pri>
|
||||
<State sr="con0">
|
||||
<code>10</code>
|
||||
<Int sr="arg0" val="3"/>
|
||||
</State>
|
||||
</Profile>
|
||||
<Profile sr="prof3" ve="2">
|
||||
<cdate>1380613730755</cdate>
|
||||
<clp>true</clp>
|
||||
<edate>1383003498566</edate>
|
||||
<id>3</id>
|
||||
<mid0>10</mid0>
|
||||
<mid1>20</mid1>
|
||||
<nme>HA Power AC</nme>
|
||||
<pri>10</pri>
|
||||
<State sr="con0">
|
||||
<code>10</code>
|
||||
<Int sr="arg0" val="1"/>
|
||||
</State>
|
||||
</Profile>
|
||||
<Profile sr="prof5" ve="2">
|
||||
<cdate>1380496514959</cdate>
|
||||
<cldm>1500</cldm>
|
||||
<clp>true</clp>
|
||||
<edate>1382769618501</edate>
|
||||
<id>5</id>
|
||||
<mid0>19</mid0>
|
||||
<nme>HA Battery Changed</nme>
|
||||
<Event sr="con0" ve="2">
|
||||
<code>203</code>
|
||||
<pri>0</pri>
|
||||
</Event>
|
||||
</Profile>
|
||||
<Project sr="proj0">
|
||||
<cdate>1381110247781</cdate>
|
||||
<name>Home Assistant</name>
|
||||
<pids>3,25,26,5</pids>
|
||||
<scenes>Variable Query,Home Assistant Start</scenes>
|
||||
<tids>20,19,9,24,4,8,22,10,30,31,16,6,15,35,13,23,14,11,12,7,28,32,29</tids>
|
||||
<Kid sr="Kid">
|
||||
<launchID>12</launchID>
|
||||
<pkg>nl.paulus.homeassistant</pkg>
|
||||
<vnme>1.2</vnme>
|
||||
<vnum>16</vnum>
|
||||
</Kid>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>cust_animal_penguin</nme>
|
||||
</Img>
|
||||
</Project>
|
||||
<Scene sr="sceneHome Assistant Start">
|
||||
<cdate>1381113309678</cdate>
|
||||
<edate>1381162068611</edate>
|
||||
<heightLand>-1</heightLand>
|
||||
<heightPort>688</heightPort>
|
||||
<nme>Home Assistant Start</nme>
|
||||
<widthLand>-1</widthLand>
|
||||
<widthPort>523</widthPort>
|
||||
<TextElement sr="elements0" ve="2">
|
||||
<flags>4</flags>
|
||||
<geom>0,17,523,107,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">TextTitle</Str>
|
||||
<Str sr="arg1" ve="3">Home Assistant</Str>
|
||||
<Int sr="arg2" val="33"/>
|
||||
<Int sr="arg3" val="100"/>
|
||||
<Str sr="arg4" ve="3">#FFFFFFFF</Str>
|
||||
<Int sr="arg5" val="0"/>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="0"/>
|
||||
</TextElement>
|
||||
<ListElement sr="elements1">
|
||||
<flags>4</flags>
|
||||
<geom>23,136,477,514,-1,-1,-1,-1</geom>
|
||||
<itemclickTask>13</itemclickTask>
|
||||
<Str sr="arg0" ve="3">Menu1</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
<Scene sr="arg4">
|
||||
<Scene sr="val">
|
||||
<cdate>1381113396824</cdate>
|
||||
<edate>1381113396824</edate>
|
||||
<heightLand>-1</heightLand>
|
||||
<heightPort>100</heightPort>
|
||||
<nme>Builtin Item Layout</nme>
|
||||
<widthLand>-1</widthLand>
|
||||
<widthPort>440</widthPort>
|
||||
<ImageElement sr="elements0">
|
||||
<flags>5</flags>
|
||||
<geom>340,10,90,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Icon</Str>
|
||||
<Img sr="arg1" ve="2">
|
||||
<nme>hd_aaa_ext_tiles_small</nme>
|
||||
</Img>
|
||||
<Int sr="arg2" val="255"/>
|
||||
</ImageElement>
|
||||
<TextElement sr="elements1" ve="2">
|
||||
<flags>5</flags>
|
||||
<geom>60,10,270,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Label</Str>
|
||||
<Str sr="arg1" ve="3"/>
|
||||
<Int sr="arg2" val="18"/>
|
||||
<Int sr="arg3"/>
|
||||
<Str sr="arg4" ve="3">#FFFFFFFF</Str>
|
||||
<Int sr="arg5" val="3"/>
|
||||
<Int sr="arg6"/>
|
||||
<Int sr="arg7"/>
|
||||
</TextElement>
|
||||
<TextElement sr="elements2" ve="2">
|
||||
<flags>1</flags>
|
||||
<geom>10,10,40,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Index</Str>
|
||||
<Str sr="arg1" ve="3">1.</Str>
|
||||
<Int sr="arg2" val="18"/>
|
||||
<Int sr="arg3"/>
|
||||
<Str sr="arg4" ve="3">#FFFFFFFF</Str>
|
||||
<Int sr="arg5" val="3"/>
|
||||
<Int sr="arg6"/>
|
||||
<Int sr="arg7"/>
|
||||
</TextElement>
|
||||
<PropertiesElement sr="props">
|
||||
<Int sr="arg0" val="1"/>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#00000000</Str>
|
||||
<Int sr="arg3" val="0"/>
|
||||
<Str sr="arg4" ve="3">Builtin Item Layout</Str>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Img sr="arg6" ve="2"/>
|
||||
<Str sr="arg7" ve="3"/>
|
||||
</PropertiesElement>
|
||||
</Scene>
|
||||
</Scene>
|
||||
<Int sr="arg5" val="1"/>
|
||||
<Int sr="arg6" val="1"/>
|
||||
<RectElement sr="background">
|
||||
<flags>4</flags>
|
||||
<geom>-1,-1,-1,-1,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3"/>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#77333333</Str>
|
||||
<Str sr="arg3" ve="3">#77333333</Str>
|
||||
<Int sr="arg4" val="0"/>
|
||||
<Str sr="arg5" ve="3">#FF000000</Str>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="0"/>
|
||||
</RectElement>
|
||||
<ListElementItem sr="item0">
|
||||
<label>Light On</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Light On</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_aaa_ext_sun</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
<ListElementItem sr="item1">
|
||||
<label>Light Off</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Light Off</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_device_access_bightness_low</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
<ListElementItem sr="item2">
|
||||
<label>Start Fireplace</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Start Fireplace</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_aaa_ext_coffee</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
<ListElementItem sr="item3">
|
||||
<label>Start Epic Sax</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Start Epic Sax</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_aaa_ext_guitar</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
<ListElementItem sr="item4">
|
||||
<label>Settings</label>
|
||||
<Action sr="action" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Setup</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icon" ve="2">
|
||||
<nme>hd_action_settings</nme>
|
||||
</Img>
|
||||
</ListElementItem>
|
||||
</ListElement>
|
||||
<PropertiesElement sr="props">
|
||||
<Int sr="arg0" val="1"/>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#DA000000</Str>
|
||||
<Int sr="arg3" val="0"/>
|
||||
<Str sr="arg4" ve="3">Home Assistant Start</Str>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Img sr="arg6" ve="2"/>
|
||||
<Str sr="arg7" ve="3"/>
|
||||
</PropertiesElement>
|
||||
</Scene>
|
||||
<Scene sr="sceneVariable Query">
|
||||
<cdate>1381112175910</cdate>
|
||||
<edate>1381112254701</edate>
|
||||
<heightLand>-1</heightLand>
|
||||
<heightPort>380</heightPort>
|
||||
<nme>Variable Query</nme>
|
||||
<widthLand>-1</widthLand>
|
||||
<widthPort>440</widthPort>
|
||||
<TextElement sr="elements0" ve="2">
|
||||
<flags>4</flags>
|
||||
<geom>8,0,432,96,8,0,432,96</geom>
|
||||
<Str sr="arg0" ve="3">Title</Str>
|
||||
<Str sr="arg1" ve="3">Title</Str>
|
||||
<Int sr="arg2" val="32"/>
|
||||
<Int sr="arg3"/>
|
||||
<Str sr="arg4" ve="3">#FF0099CC</Str>
|
||||
<Int sr="arg5" val="3"/>
|
||||
<Int sr="arg6"/>
|
||||
<Int sr="arg7"/>
|
||||
</TextElement>
|
||||
<RectElement sr="elements1">
|
||||
<flags>5</flags>
|
||||
<geom>0,96,440,4,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Header</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#77333333</Str>
|
||||
<Str sr="arg3" ve="3">#77333333</Str>
|
||||
<Int sr="arg4" val="0"/>
|
||||
<Str sr="arg5" ve="3">#FF000000</Str>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="0"/>
|
||||
</RectElement>
|
||||
<EditTextElement sr="elements2">
|
||||
<flags>13</flags>
|
||||
<geom>20,156,400,96,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">TextEdit1</Str>
|
||||
<Str sr="arg1" ve="3"/>
|
||||
<Int sr="arg2" val="16"/>
|
||||
<Int sr="arg3" val="100"/>
|
||||
<Str sr="arg4" ve="3">#FFFFFFFF</Str>
|
||||
<Int sr="arg5" val="0"/>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="1000"/>
|
||||
</EditTextElement>
|
||||
<RectElement sr="elements3">
|
||||
<flags>5</flags>
|
||||
<geom>0,300,440,4,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Footer</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#77333333</Str>
|
||||
<Str sr="arg3" ve="3">#77333333</Str>
|
||||
<Int sr="arg4" val="0"/>
|
||||
<Str sr="arg5" ve="3">#FF000000</Str>
|
||||
<Int sr="arg6" val="0"/>
|
||||
<Int sr="arg7" val="0"/>
|
||||
</RectElement>
|
||||
<ImageElement sr="elements4">
|
||||
<clickTask>-936</clickTask>
|
||||
<flags>4</flags>
|
||||
<geom>70,300,80,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Accept</Str>
|
||||
<Img sr="arg1" ve="2">
|
||||
<nme>hd_navigation_accept</nme>
|
||||
</Img>
|
||||
<Int sr="arg2" val="255"/>
|
||||
</ImageElement>
|
||||
<ImageElement sr="elements5">
|
||||
<clickTask>-936</clickTask>
|
||||
<flags>4</flags>
|
||||
<geom>290,300,80,80,-1,-1,-1,-1</geom>
|
||||
<Str sr="arg0" ve="3">Cancel</Str>
|
||||
<Img sr="arg1" ve="2">
|
||||
<nme>hd_content_remove</nme>
|
||||
</Img>
|
||||
<Int sr="arg2" val="255"/>
|
||||
</ImageElement>
|
||||
<PropertiesElement sr="props">
|
||||
<Int sr="arg0" val="1"/>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Str sr="arg2" ve="3">#FF000000</Str>
|
||||
<Int sr="arg3" val="0"/>
|
||||
<Str sr="arg4" ve="3">Variable Query</Str>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Img sr="arg6" ve="2"/>
|
||||
<Str sr="arg7" ve="3"/>
|
||||
</PropertiesElement>
|
||||
</Scene>
|
||||
<Task sr="task10">
|
||||
<cdate>1380613530339</cdate>
|
||||
<edate>1383030846230</edate>
|
||||
<id>10</id>
|
||||
<nme>Charging AC</nme>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">_Update Charging</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">ac</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task11">
|
||||
<cdate>1381110672417</cdate>
|
||||
<edate>1384035370683</edate>
|
||||
<id>11</id>
|
||||
<nme>Open Debug Interface</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>104</code>
|
||||
<Str sr="arg0" ve="3">http://%HA_HOST:%HA_PORT/?api_password=%HA_API_PASSWORD</Str>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task12">
|
||||
<cdate>1381113015963</cdate>
|
||||
<edate>1384219718372</edate>
|
||||
<id>12</id>
|
||||
<nme>Start Screen</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>47</code>
|
||||
<Str sr="arg0" ve="3">Home Assistant Start</Str>
|
||||
<Int sr="arg1" val="5"/>
|
||||
<Int sr="arg2" val="100"/>
|
||||
<Int sr="arg3" val="100"/>
|
||||
<Int sr="arg4" val="1"/>
|
||||
<Int sr="arg5" val="0"/>
|
||||
<Int sr="arg6" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>49</code>
|
||||
<Str sr="arg0" ve="3">Home Assistant Start</Str>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>cust_animal_penguin</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task13">
|
||||
<cdate>1381114398467</cdate>
|
||||
<edate>1381114398467</edate>
|
||||
<id>13</id>
|
||||
<pri>11</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>49</code>
|
||||
<lhs>%tap_label</lhs>
|
||||
<op>2</op>
|
||||
<rhs>Settings</rhs>
|
||||
<Str sr="arg0" ve="3">Home Assistant Start</Str>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task14">
|
||||
<cdate>1381114829583</cdate>
|
||||
<edate>1385537340259</edate>
|
||||
<id>14</id>
|
||||
<nme>API Fire Event</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>116</code>
|
||||
<Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">/api/events/%par1</Str>
|
||||
<Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD</Str>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>548</code>
|
||||
<Str sr="arg0" ve="3">Fired event %par1</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task15">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386787405570</edate>
|
||||
<id>15</id>
|
||||
<nme>Light On</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">light/turn_on</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hd_aaa_ext_sun</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task16">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1385172575157</edate>
|
||||
<id>16</id>
|
||||
<nme>Start Epic Sax</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">chromecast/start_epic_sax</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hd_aaa_ext_guitar</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task19">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386695312804</edate>
|
||||
<id>19</id>
|
||||
<nme>Update Battery</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>547</code>
|
||||
<label>Make sure charging var exist</label>
|
||||
<lhs>%HA_CHARGING</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_CHARGING</Str>
|
||||
<Str sr="arg1" ve="3">none</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>116</code>
|
||||
<Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">/api/states/devices.%HA_DEVICE_NAME.charging</Str>
|
||||
<Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD
|
||||
new_state=%HA_CHARGING
|
||||
attributes={"battery":%BATT}</Str>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task20">
|
||||
<cdate>1380613530339</cdate>
|
||||
<edate>1386695398714</edate>
|
||||
<id>20</id>
|
||||
<nme>Charging None</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">_Update Charging</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">none</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task22">
|
||||
<cdate>1380613530339</cdate>
|
||||
<edate>1383030909347</edate>
|
||||
<id>22</id>
|
||||
<nme>Charging Wireless</nme>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">_Update Charging</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">wireless</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task23">
|
||||
<cdate>1380613530339</cdate>
|
||||
<edate>1383030849758</edate>
|
||||
<id>23</id>
|
||||
<nme>Charging USB</nme>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">_Update Charging</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">usb</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task24">
|
||||
<cdate>1381114829583</cdate>
|
||||
<edate>1385537314797</edate>
|
||||
<id>24</id>
|
||||
<nme>API Call Service</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>116</code>
|
||||
<Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">/api/services/%par1</Str>
|
||||
<Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD</Str>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>548</code>
|
||||
<Str sr="arg0" ve="3">Called service %par1</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task28">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172806993</edate>
|
||||
<id>28</id>
|
||||
<nme>Volume Down</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/volume_down</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_images_rotate_left</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task29">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172552470</edate>
|
||||
<id>29</id>
|
||||
<nme>Play Pause</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/media_play_pause</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_av_pause</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task30">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172803463</edate>
|
||||
<id>30</id>
|
||||
<nme>Volume Mute Toggle</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/volume_mute</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_device_access_volume_muted</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task31">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172559562</edate>
|
||||
<id>31</id>
|
||||
<nme>Next Track</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/media_next_track</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_av_next</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task32">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1385172567948</edate>
|
||||
<id>32</id>
|
||||
<nme>Prev Track</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/media_prev_track</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_av_previous</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task35">
|
||||
<cdate>1381114829583</cdate>
|
||||
<edate>1385537324133</edate>
|
||||
<id>35</id>
|
||||
<nme>API Call Service With Data</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>116</code>
|
||||
<Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">/api/services/%par1</Str>
|
||||
<Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD
|
||||
service_data=%par2</Str>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>548</code>
|
||||
<Str sr="arg0" ve="3">Called service %par1</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task4">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386787393520</edate>
|
||||
<id>4</id>
|
||||
<nme>Light Off</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">light/turn_off</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_device_access_bightness_low</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task6">
|
||||
<cdate>1380522560890</cdate>
|
||||
<edate>1383958813434</edate>
|
||||
<id>6</id>
|
||||
<nme>Setup</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>118</code>
|
||||
<lhs>%HA_HOST</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">icanhazip.com</Str>
|
||||
<Str sr="arg1" ve="3"/>
|
||||
<Str sr="arg2" ve="3"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Int sr="arg4" val="10"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
<Str sr="arg6" ve="3">%HA_HOST</Str>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>547</code>
|
||||
<lhs>%HA_HOST</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_HOST</Str>
|
||||
<Str sr="arg1" ve="3">%HTTPD</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act2" ve="3">
|
||||
<code>547</code>
|
||||
<lhs>%HA_PORT</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_PORT</Str>
|
||||
<Str sr="arg1" ve="3">8123</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act3" ve="3">
|
||||
<code>547</code>
|
||||
<lhs>%HA_API_PASSWORD</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_API_PASSWORD</Str>
|
||||
<Str sr="arg1" ve="3">My password</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act4" ve="3">
|
||||
<code>547</code>
|
||||
<lhs>%HA_DEVICE_NAME</lhs>
|
||||
<op>10</op>
|
||||
<rhs></rhs>
|
||||
<Str sr="arg0" ve="3">%HA_DEVICE_NAME</Str>
|
||||
<Str sr="arg1" ve="3">%DEVMOD</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act5" ve="3">
|
||||
<code>595</code>
|
||||
<Str sr="arg0" ve="3">Host</Str>
|
||||
<Str sr="arg1" ve="3">%HA_HOST</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Str sr="arg3" ve="3">%HA_HOST</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3">Variable Query</Str>
|
||||
<Int sr="arg6" val="40"/>
|
||||
<Int sr="arg7" val="1"/>
|
||||
</Action>
|
||||
<Action sr="act6" ve="3">
|
||||
<code>595</code>
|
||||
<Str sr="arg0" ve="3">Port</Str>
|
||||
<Str sr="arg1" ve="3">%HA_PORT</Str>
|
||||
<Int sr="arg2" val="4"/>
|
||||
<Str sr="arg3" ve="3">%HA_PORT</Str>
|
||||
<Str sr="arg4" ve="3">%HA_PORT</Str>
|
||||
<Str sr="arg5" ve="3">Variable Query</Str>
|
||||
<Int sr="arg6" val="40"/>
|
||||
<Int sr="arg7" val="1"/>
|
||||
</Action>
|
||||
<Action sr="act7" ve="3">
|
||||
<code>595</code>
|
||||
<Str sr="arg0" ve="3">API Password</Str>
|
||||
<Str sr="arg1" ve="3">%HA_API_PASSWORD</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Str sr="arg3" ve="3">%HA_API_PASSWORD</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3">Variable Query</Str>
|
||||
<Int sr="arg6" val="40"/>
|
||||
<Int sr="arg7" val="1"/>
|
||||
</Action>
|
||||
<Action sr="act8" ve="3">
|
||||
<code>595</code>
|
||||
<label>Ask device name</label>
|
||||
<Str sr="arg0" ve="3">Device name</Str>
|
||||
<Str sr="arg1" ve="3">%HA_DEVICE_NAME</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Str sr="arg3" ve="3">%HA_DEVICE_NAME</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3">Variable Query</Str>
|
||||
<Int sr="arg6" val="40"/>
|
||||
<Int sr="arg7" val="1"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hd_ab_action_settings</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task7">
|
||||
<cdate>1384035383644</cdate>
|
||||
<edate>1386787431769</edate>
|
||||
<id>7</id>
|
||||
<nme>Volume Up</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">keyboard/volume_up</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hl_images_rotate_right</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
<Task sr="task8">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386695263222</edate>
|
||||
<id>8</id>
|
||||
<nme>_Update Charging</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>547</code>
|
||||
<Str sr="arg0" ve="3">%HA_CHARGING</Str>
|
||||
<Str sr="arg1" ve="3">%par1</Str>
|
||||
<Int sr="arg2" val="0"/>
|
||||
<Int sr="arg3" val="0"/>
|
||||
</Action>
|
||||
<Action sr="act1" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">Update Battery</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3"/>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
</Task>
|
||||
<Task sr="task9">
|
||||
<cdate>1380262442154</cdate>
|
||||
<edate>1386787379497</edate>
|
||||
<id>9</id>
|
||||
<nme>Start Fireplace</nme>
|
||||
<pri>10</pri>
|
||||
<Action sr="act0" ve="3">
|
||||
<code>130</code>
|
||||
<Str sr="arg0" ve="3">API Call Service</Str>
|
||||
<Int sr="arg1" val="0"/>
|
||||
<Int sr="arg2" val="5"/>
|
||||
<Str sr="arg3" ve="3">chromecast/start_fireplace</Str>
|
||||
<Str sr="arg4" ve="3"/>
|
||||
<Str sr="arg5" ve="3"/>
|
||||
</Action>
|
||||
<Img sr="icn" ve="2">
|
||||
<nme>hd_aaa_ext_coffee</nme>
|
||||
</Img>
|
||||
</Task>
|
||||
</TaskerData>
|
||||
163
config/configuration.yaml.example
Normal file
@@ -0,0 +1,163 @@
|
||||
homeassistant:
|
||||
# Omitted values in this section will be auto detected using freegeoip.net
|
||||
|
||||
# Location required to calculate the time the sun rises and sets
|
||||
latitude: 32.87336
|
||||
longitude: 117.22743
|
||||
|
||||
# C for Celcius, F for Fahrenheit
|
||||
temperature_unit: C
|
||||
|
||||
# Pick yours from here:
|
||||
# http://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
time_zone: America/Los_Angeles
|
||||
|
||||
# Name of the location where Home Assistant is running
|
||||
name: Home
|
||||
|
||||
http:
|
||||
api_password: mypass
|
||||
# Set to 1 to enable development mode
|
||||
# development: 1
|
||||
|
||||
light:
|
||||
# platform: hue
|
||||
|
||||
wink:
|
||||
# Get your token at https://winkbearertoken.appspot.com
|
||||
access_token: 'YOUR_TOKEN'
|
||||
|
||||
device_tracker:
|
||||
# The following types are available: ddwrt, netgear, tomato, luci,
|
||||
# and nmap_tracker
|
||||
platform: netgear
|
||||
host: 192.168.1.1
|
||||
username: admin
|
||||
password: PASSWORD
|
||||
# http_id is needed for Tomato routers only
|
||||
# http_id: ABCDEFGHH
|
||||
# For nmap_tracker, only the IP addresses to scan are needed:
|
||||
# hosts: 192.168.1.1/24 # netmask prefix notation or
|
||||
# hosts: 192.168.1.1-255 # address range
|
||||
|
||||
chromecast:
|
||||
|
||||
switch:
|
||||
platform: wemo
|
||||
|
||||
thermostat:
|
||||
platform: nest
|
||||
# Required: username and password that are used to login to the Nest thermostat.
|
||||
username: myemail@mydomain.com
|
||||
password: mypassword
|
||||
|
||||
downloader:
|
||||
download_dir: downloads
|
||||
|
||||
notify:
|
||||
platform: pushbullet
|
||||
api_key: ABCDEFGHJKLMNOPQRSTUVXYZ
|
||||
|
||||
device_sun_light_trigger:
|
||||
# Optional: specify a specific light/group of lights that has to be turned on
|
||||
light_group: group.living_room
|
||||
# Optional: specify which light profile to use when turning lights on
|
||||
light_profile: relax
|
||||
# Optional: disable lights being turned off when everybody leaves the house
|
||||
# disable_turn_off: 1
|
||||
|
||||
# A comma separated list of states that have to be tracked as a single group
|
||||
# Grouped states should share the same type of states (ON/OFF or HOME/NOT_HOME)
|
||||
group:
|
||||
living_room:
|
||||
- light.Bowl
|
||||
- light.Ceiling
|
||||
- light.TV_back_light
|
||||
children:
|
||||
- device_tracker.child_1
|
||||
- device_tracker.child_2
|
||||
|
||||
process:
|
||||
# items are which processes to look for: <entity_id>: <search string within ps>
|
||||
xbmc: XBMC.App
|
||||
|
||||
example:
|
||||
|
||||
simple_alarm:
|
||||
# Which light/light group has to flash when a known device comes home
|
||||
known_light: light.Bowl
|
||||
# Which light/light group has to flash red when light turns on while no one home
|
||||
unknown_light: group.living_room
|
||||
|
||||
browser:
|
||||
|
||||
keyboard:
|
||||
|
||||
automation:
|
||||
platform: state
|
||||
alias: Sun starts shining
|
||||
|
||||
state_entity_id: sun.sun
|
||||
# Next two are optional, omit to match all
|
||||
state_from: below_horizon
|
||||
state_to: above_horizon
|
||||
|
||||
execute_service: light.turn_off
|
||||
service_entity_id: group.living_room
|
||||
|
||||
automation 2:
|
||||
platform: time
|
||||
alias: Beer o Clock
|
||||
|
||||
time_hours: 16
|
||||
time_minutes: 0
|
||||
time_seconds: 0
|
||||
|
||||
execute_service: notify.notify
|
||||
service_data:
|
||||
message: It's 4, time for beer!
|
||||
|
||||
sensor:
|
||||
platform: systemmonitor
|
||||
resources:
|
||||
- type: 'disk_use_percent'
|
||||
arg: '/'
|
||||
- type: 'disk_use_percent'
|
||||
arg: '/home'
|
||||
- type: 'disk_use'
|
||||
arg: '/home'
|
||||
- type: 'disk_free'
|
||||
arg: '/'
|
||||
- type: 'memory_use_percent'
|
||||
- type: 'memory_use'
|
||||
- type: 'memory_free'
|
||||
- type: 'processor_use'
|
||||
- type: 'process'
|
||||
arg: 'octave-cli'
|
||||
|
||||
script:
|
||||
# Turns on the bedroom lights and then the living room lights 1 minute later
|
||||
wakeup:
|
||||
alias: Wake Up
|
||||
sequence:
|
||||
# alias is optional
|
||||
- alias: Bedroom lights on
|
||||
execute_service: light.turn_on
|
||||
service_data:
|
||||
entity_id: group.bedroom
|
||||
- delay:
|
||||
# supports seconds, milliseconds, minutes, hours, etc.
|
||||
minutes: 1
|
||||
- alias: Living room lights on
|
||||
execute_service: light.turn_on
|
||||
service_data:
|
||||
entity_id: group.living_room
|
||||
|
||||
scene:
|
||||
- name: Romantic
|
||||
entities:
|
||||
light.tv_back_light: on
|
||||
light.ceiling:
|
||||
state: on
|
||||
xy_color: [0.33, 0.66]
|
||||
brightness: 200
|
||||
136
config/custom_components/example.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
custom_components.example
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Example component to target an entity_id to:
|
||||
- turn it on at 7AM in the morning
|
||||
- turn it on if anyone comes home and it is off
|
||||
- turn it off if all lights are turned off
|
||||
- turn it off if all people leave the house
|
||||
- offer a service to turn it on for 10 seconds
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Example custom component you will need to add the following to
|
||||
your configuration.yaml file.
|
||||
|
||||
example:
|
||||
target: TARGET_ENTITY
|
||||
|
||||
Variable:
|
||||
|
||||
target
|
||||
*Required
|
||||
TARGET_ENTITY should be one of your devices that can be turned on and off,
|
||||
ie a light or a switch. Example value could be light.Ceiling or switch.AC
|
||||
(if you have these devices with those names).
|
||||
"""
|
||||
import time
|
||||
import logging
|
||||
|
||||
from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_ON, STATE_OFF
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.helpers import validate_config
|
||||
import homeassistant.components as core
|
||||
|
||||
# The domain of your component. Should be equal to the name of your component
|
||||
DOMAIN = "example"
|
||||
|
||||
# List of component names (string) your component depends upon
|
||||
# We depend on group because group will be loaded after all the components that
|
||||
# initialize devices have been setup.
|
||||
DEPENDENCIES = ['group']
|
||||
|
||||
# Configuration key for the entity id we are targetting
|
||||
CONF_TARGET = 'target'
|
||||
|
||||
# Name of the service that we expose
|
||||
SERVICE_FLASH = 'flash'
|
||||
|
||||
# Shortcut for the logger
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup example component. """
|
||||
|
||||
# Validate that all required config options are given
|
||||
if not validate_config(config, {DOMAIN: [CONF_TARGET]}, _LOGGER):
|
||||
return False
|
||||
|
||||
target_id = config[DOMAIN][CONF_TARGET]
|
||||
|
||||
# Validate that the target entity id exists
|
||||
if hass.states.get(target_id) is None:
|
||||
_LOGGER.error("Target entity id %s does not exist", target_id)
|
||||
|
||||
# Tell the bootstrapper that we failed to initialize
|
||||
return False
|
||||
|
||||
# We will use the component helper methods to check the states.
|
||||
device_tracker = loader.get_component('device_tracker')
|
||||
light = loader.get_component('light')
|
||||
|
||||
def track_devices(entity_id, old_state, new_state):
|
||||
""" Called when the group.all devices change state. """
|
||||
|
||||
# If anyone comes home and the core is not on, turn it on.
|
||||
if new_state.state == STATE_HOME and not core.is_on(hass, target_id):
|
||||
|
||||
core.turn_on(hass, target_id)
|
||||
|
||||
# If all people leave the house and the core is on, turn it off
|
||||
elif new_state.state == STATE_NOT_HOME and core.is_on(hass, target_id):
|
||||
|
||||
core.turn_off(hass, target_id)
|
||||
|
||||
# Register our track_devices method to receive state changes of the
|
||||
# all tracked devices group.
|
||||
hass.states.track_change(
|
||||
device_tracker.ENTITY_ID_ALL_DEVICES, track_devices)
|
||||
|
||||
def wake_up(now):
|
||||
""" Turn it on in the morning if there are people home and
|
||||
it is not already on. """
|
||||
|
||||
if device_tracker.is_on(hass) and not core.is_on(hass, target_id):
|
||||
_LOGGER.info('People home at 7AM, turning it on')
|
||||
core.turn_on(hass, target_id)
|
||||
|
||||
# Register our wake_up service to be called at 7AM in the morning
|
||||
hass.track_time_change(wake_up, hour=7, minute=0, second=0)
|
||||
|
||||
def all_lights_off(entity_id, old_state, new_state):
|
||||
""" If all lights turn off, turn off. """
|
||||
|
||||
if core.is_on(hass, target_id):
|
||||
_LOGGER.info('All lights have been turned off, turning it off')
|
||||
core.turn_off(hass, target_id)
|
||||
|
||||
# Register our all_lights_off method to be called when all lights turn off
|
||||
hass.states.track_change(
|
||||
light.ENTITY_ID_ALL_LIGHTS, all_lights_off, STATE_ON, STATE_OFF)
|
||||
|
||||
def flash_service(call):
|
||||
""" Service that will turn the target off for 10 seconds
|
||||
if on and vice versa. """
|
||||
|
||||
if core.is_on(hass, target_id):
|
||||
core.turn_off(hass, target_id)
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
core.turn_on(hass, target_id)
|
||||
|
||||
else:
|
||||
core.turn_on(hass, target_id)
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
core.turn_off(hass, target_id)
|
||||
|
||||
# Register our service with HASS.
|
||||
hass.services.register(DOMAIN, SERVICE_FLASH, flash_service)
|
||||
|
||||
# Tells the bootstrapper that the component was successfully initialized
|
||||
return True
|
||||
28
config/custom_components/hello_world.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
custom_components.hello_world
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Implements the bare minimum that a component should implement.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the hello_word component you will need to add the following to your
|
||||
configuration.yaml file.
|
||||
|
||||
hello_world:
|
||||
"""
|
||||
|
||||
# The domain of your component. Should be equal to the name of your component
|
||||
DOMAIN = "hello_world"
|
||||
|
||||
# List of component names (string) your component depends upon
|
||||
DEPENDENCIES = []
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup our skeleton component. """
|
||||
|
||||
# States are in the format DOMAIN.OBJECT_ID
|
||||
hass.states.set('hello_world.Hello_World', 'Works!')
|
||||
|
||||
# return boolean to indicate that initialization was successful
|
||||
return True
|
||||
59
config/custom_components/mqtt_example.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
custom_components.mqtt_example
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Shows how to communicate with MQTT. Follows a topic on MQTT and updates the
|
||||
state of an entity to the last message received on that topic.
|
||||
|
||||
Also offers a service 'set_state' that will publish a message on the topic that
|
||||
will be passed via MQTT to our message received listener. Call the service with
|
||||
example payload {"new_state": "some new state"}.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the mqtt_example component you will need to add the following to your
|
||||
configuration.yaml file.
|
||||
|
||||
mqtt_example:
|
||||
topic: home-assistant/mqtt_example
|
||||
|
||||
"""
|
||||
import homeassistant.loader as loader
|
||||
|
||||
# The domain of your component. Should be equal to the name of your component
|
||||
DOMAIN = "mqtt_example"
|
||||
|
||||
# List of component names (string) your component depends upon
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
|
||||
CONF_TOPIC = 'topic'
|
||||
DEFAULT_TOPIC = 'home-assistant/mqtt_example'
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup our mqtt_example component. """
|
||||
mqtt = loader.get_component('mqtt')
|
||||
topic = config[DOMAIN].get('topic', DEFAULT_TOPIC)
|
||||
entity_id = 'mqtt_example.last_message'
|
||||
|
||||
# Listen to a message on MQTT
|
||||
|
||||
def message_received(topic, payload, qos):
|
||||
""" A new MQTT message has been received. """
|
||||
hass.states.set(entity_id, payload)
|
||||
|
||||
mqtt.subscribe(hass, topic, message_received)
|
||||
|
||||
hass.states.set(entity_id, 'No messages')
|
||||
|
||||
# Service to publish a message on MQTT
|
||||
|
||||
def set_state_service(call):
|
||||
""" Service to send a message. """
|
||||
mqtt.publish(hass, topic, call.data.get('new_state'))
|
||||
|
||||
# Register our service with Home Assistant
|
||||
hass.services.register(DOMAIN, 'set_state', set_state_service)
|
||||
|
||||
# return boolean to indicate that initialization was successful
|
||||
return True
|
||||
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 222 KiB |
BIN
docs/screenshots.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/states.png
|
Before Width: | Height: | Size: 160 KiB |
@@ -1,39 +0,0 @@
|
||||
[common]
|
||||
latitude=32.87336
|
||||
longitude=-117.22743
|
||||
|
||||
[httpinterface]
|
||||
api_password=mypass
|
||||
|
||||
[light.hue]
|
||||
host=192.168.1.2
|
||||
|
||||
[device_tracker.tomato]
|
||||
host=192.168.1.1
|
||||
username=admin
|
||||
password=PASSWORD
|
||||
http_id=aaaaaaaaaaaaaaa
|
||||
|
||||
[device_tracker.netgear]
|
||||
host=192.168.1.1
|
||||
username=admin
|
||||
password=PASSWORD
|
||||
|
||||
[chromecast]
|
||||
|
||||
[wemo]
|
||||
|
||||
[downloader]
|
||||
download_dir=downloads
|
||||
|
||||
[device_sun_light_trigger]
|
||||
# Example how you can specify a specific group that has to be turned on
|
||||
# light_group=group.living_room
|
||||
# Example how you can specify which light profile to use when turning lights on
|
||||
# light_profile=relax
|
||||
|
||||
# A comma seperated list of states that have to be tracked
|
||||
# As a single group
|
||||
[group]
|
||||
living_room=light.Bowl,light.Ceiling,light.TV_back_light
|
||||
bedroom=light.Bed_light
|
||||
@@ -1,577 +0,0 @@
|
||||
"""
|
||||
homeassistant
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Home Assistant is a Home Automation framework for observing the state
|
||||
of entities and react to changes.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import datetime as dt
|
||||
import functools as ft
|
||||
|
||||
import homeassistant.util as util
|
||||
|
||||
MATCH_ALL = '*'
|
||||
|
||||
DOMAIN = "homeassistant"
|
||||
|
||||
SERVICE_HOMEASSISTANT_STOP = "stop"
|
||||
|
||||
EVENT_HOMEASSISTANT_START = "homeassistant_start"
|
||||
EVENT_STATE_CHANGED = "state_changed"
|
||||
EVENT_TIME_CHANGED = "time_changed"
|
||||
|
||||
TIMER_INTERVAL = 10 # seconds
|
||||
|
||||
# We want to be able to fire every time a minute starts (seconds=0).
|
||||
# We want this so other modules can use that to make sure they fire
|
||||
# every minute.
|
||||
assert 60 % TIMER_INTERVAL == 0, "60 % TIMER_INTERVAL should be 0!"
|
||||
|
||||
BUS_NUM_THREAD = 4
|
||||
BUS_REPORT_BUSY_TIMEOUT = dt.timedelta(minutes=1)
|
||||
PRIO_SERVICE_DEFAULT = 1
|
||||
PRIO_EVENT_STATE = 2
|
||||
PRIO_EVENT_TIME = 3
|
||||
PRIO_EVENT_DEFAULT = 4
|
||||
|
||||
|
||||
def start_home_assistant(bus):
|
||||
""" Start home assistant. """
|
||||
request_shutdown = threading.Event()
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_HOMEASSISTANT_STOP,
|
||||
lambda service: request_shutdown.set())
|
||||
|
||||
Timer(bus)
|
||||
|
||||
bus.fire_event(EVENT_HOMEASSISTANT_START)
|
||||
|
||||
while not request_shutdown.isSet():
|
||||
try:
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
|
||||
def _process_match_param(parameter):
|
||||
""" Wraps parameter in a list if it is not one and returns it. """
|
||||
if not parameter:
|
||||
return MATCH_ALL
|
||||
elif isinstance(parameter, list):
|
||||
return parameter
|
||||
else:
|
||||
return [parameter]
|
||||
|
||||
|
||||
def _matcher(subject, pattern):
|
||||
""" Returns True if subject matches the pattern.
|
||||
|
||||
Pattern is either a list of allowed subjects or a `MATCH_ALL`.
|
||||
"""
|
||||
return MATCH_ALL == pattern or subject in pattern
|
||||
|
||||
|
||||
def track_state_change(bus, entity_id, action, from_state=None, to_state=None):
|
||||
""" Helper method to track specific state changes. """
|
||||
from_state = _process_match_param(from_state)
|
||||
to_state = _process_match_param(to_state)
|
||||
|
||||
@ft.wraps(action)
|
||||
def state_listener(event):
|
||||
""" State change listener that listens for specific state changes. """
|
||||
if entity_id == event.data['entity_id'] and \
|
||||
_matcher(event.data['old_state'].state, from_state) and \
|
||||
_matcher(event.data['new_state'].state, to_state):
|
||||
|
||||
action(event.data['entity_id'],
|
||||
event.data['old_state'],
|
||||
event.data['new_state'])
|
||||
|
||||
bus.listen_event(EVENT_STATE_CHANGED, state_listener)
|
||||
|
||||
|
||||
def track_point_in_time(bus, action, point_in_time):
|
||||
""" Adds a listener that will fire once after a spefic point in time. """
|
||||
|
||||
@ft.wraps(action)
|
||||
def point_in_time_listener(event):
|
||||
""" Listens for matching time_changed events. """
|
||||
now = event.data['now']
|
||||
|
||||
if now > point_in_time and not hasattr(point_in_time_listener, 'run'):
|
||||
|
||||
# Set variable so that we will never run twice.
|
||||
# Because the event bus might have to wait till a thread comes
|
||||
# available to execute this listener it might occur that the
|
||||
# listener gets lined up twice to be executed. This will make sure
|
||||
# the second time it does nothing.
|
||||
point_in_time_listener.run = True
|
||||
|
||||
bus.remove_event_listener(EVENT_TIME_CHANGED,
|
||||
point_in_time_listener)
|
||||
|
||||
action(now)
|
||||
|
||||
bus.listen_event(EVENT_TIME_CHANGED, point_in_time_listener)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def track_time_change(bus, action,
|
||||
year=None, month=None, day=None,
|
||||
hour=None, minute=None, second=None):
|
||||
""" Adds a listener that will fire if time matches a pattern. """
|
||||
|
||||
# We do not have to wrap the function with time pattern matching logic if
|
||||
# no pattern given
|
||||
if any((val is not None for val in
|
||||
(year, month, day, hour, minute, second))):
|
||||
|
||||
pmp = _process_match_param
|
||||
year, month, day = pmp(year), pmp(month), pmp(day)
|
||||
hour, minute, second = pmp(hour), pmp(minute), pmp(second)
|
||||
|
||||
@ft.wraps(action)
|
||||
def time_listener(event):
|
||||
""" Listens for matching time_changed events. """
|
||||
now = event.data['now']
|
||||
|
||||
mat = _matcher
|
||||
|
||||
if mat(now.year, year) and \
|
||||
mat(now.month, month) and \
|
||||
mat(now.day, day) and \
|
||||
mat(now.hour, hour) and \
|
||||
mat(now.minute, minute) and \
|
||||
mat(now.second, second):
|
||||
|
||||
action(now)
|
||||
|
||||
else:
|
||||
@ft.wraps(action)
|
||||
def time_listener(event):
|
||||
""" Fires every time event that comes in. """
|
||||
action(event.data['now'])
|
||||
|
||||
bus.listen_event(EVENT_TIME_CHANGED, time_listener)
|
||||
|
||||
|
||||
def create_bus_job_handler(logger):
|
||||
""" Creates a job handler that logs errors to supplied `logger`. """
|
||||
|
||||
def job_handler(job):
|
||||
""" Called whenever a job is available to do. """
|
||||
try:
|
||||
func, arg = job
|
||||
func(arg)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Catch any exception our service/event_listener might throw
|
||||
# We do not want to crash our ThreadPool
|
||||
logger.exception(u"BusHandler:Exception doing job")
|
||||
|
||||
return job_handler
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class ServiceCall(object):
|
||||
""" Represents a call to a service. """
|
||||
|
||||
__slots__ = ['domain', 'service', 'data']
|
||||
|
||||
def __init__(self, domain, service, data=None):
|
||||
self.domain = domain
|
||||
self.service = service
|
||||
self.data = data or {}
|
||||
|
||||
def __repr__(self):
|
||||
if self.data:
|
||||
return u"<ServiceCall {}.{}: {}>".format(
|
||||
self.domain, self.service, util.repr_helper(self.data))
|
||||
else:
|
||||
return u"<ServiceCall {}.{}>".format(self.domain, self.service)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class Event(object):
|
||||
""" Represents an event within the Bus. """
|
||||
|
||||
__slots__ = ['event_type', 'data']
|
||||
|
||||
def __init__(self, event_type, data=None):
|
||||
self.event_type = event_type
|
||||
self.data = data or {}
|
||||
|
||||
def __repr__(self):
|
||||
if self.data:
|
||||
return u"<Event {}: {}>".format(
|
||||
self.event_type, util.repr_helper(self.data))
|
||||
else:
|
||||
return u"<Event {}>".format(self.event_type)
|
||||
|
||||
|
||||
class Bus(object):
|
||||
""" Class that allows different components to communicate via services
|
||||
and events.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, thread_count=None):
|
||||
self.thread_count = thread_count or BUS_NUM_THREAD
|
||||
self._event_listeners = {}
|
||||
self._services = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.event_lock = threading.Lock()
|
||||
self.service_lock = threading.Lock()
|
||||
self.last_busy_notice = dt.datetime.now()
|
||||
|
||||
self.pool = util.ThreadPool(self.thread_count,
|
||||
create_bus_job_handler(self.logger))
|
||||
|
||||
@property
|
||||
def services(self):
|
||||
""" Dict with per domain a list of available services. """
|
||||
with self.service_lock:
|
||||
return {domain: self._services[domain].keys()
|
||||
for domain in self._services}
|
||||
|
||||
@property
|
||||
def event_listeners(self):
|
||||
""" Dict with events that is being listened for and the number
|
||||
of listeners.
|
||||
"""
|
||||
with self.event_lock:
|
||||
return {key: len(self._event_listeners[key])
|
||||
for key in self._event_listeners}
|
||||
|
||||
def has_service(self, domain, service):
|
||||
""" Returns True if specified service exists. """
|
||||
try:
|
||||
return service in self._services[domain]
|
||||
except KeyError: # if key 'domain' does not exist
|
||||
return False
|
||||
|
||||
def call_service(self, domain, service, service_data=None):
|
||||
""" Calls a service. """
|
||||
service_call = ServiceCall(domain, service, service_data)
|
||||
|
||||
with self.service_lock:
|
||||
try:
|
||||
self.pool.add_job(PRIO_SERVICE_DEFAULT,
|
||||
(self._services[domain][service],
|
||||
service_call))
|
||||
|
||||
self._check_busy()
|
||||
|
||||
except KeyError: # if key domain or service does not exist
|
||||
raise ServiceDoesNotExistError(
|
||||
u"Service does not exist: {}/{}".format(domain, service))
|
||||
|
||||
def register_service(self, domain, service, service_func):
|
||||
""" Register a service. """
|
||||
with self.service_lock:
|
||||
try:
|
||||
self._services[domain][service] = service_func
|
||||
|
||||
except KeyError: # Domain does not exist yet in self._services
|
||||
self._services[domain] = {service: service_func}
|
||||
|
||||
def fire_event(self, event_type, event_data=None):
|
||||
""" Fire an event. """
|
||||
with self.event_lock:
|
||||
# Copy the list of the current listeners because some listeners
|
||||
# remove themselves as a listener while being executed which
|
||||
# causes the iterator to be confused.
|
||||
get = self._event_listeners.get
|
||||
listeners = get(MATCH_ALL, []) + get(event_type, [])
|
||||
|
||||
event = Event(event_type, event_data)
|
||||
|
||||
self.logger.info(u"Bus:Handling {}".format(event))
|
||||
|
||||
if not listeners:
|
||||
return
|
||||
|
||||
if event_type == EVENT_TIME_CHANGED:
|
||||
prio = PRIO_EVENT_TIME
|
||||
elif event_type == EVENT_STATE_CHANGED:
|
||||
prio = PRIO_EVENT_STATE
|
||||
else:
|
||||
prio = PRIO_EVENT_DEFAULT
|
||||
|
||||
for func in listeners:
|
||||
self.pool.add_job(prio, (func, event))
|
||||
|
||||
self._check_busy()
|
||||
|
||||
def listen_event(self, event_type, listener):
|
||||
""" Listen for all events or events of a specific type.
|
||||
|
||||
To listen to all events specify the constant ``MATCH_ALL``
|
||||
as event_type.
|
||||
"""
|
||||
with self.event_lock:
|
||||
try:
|
||||
self._event_listeners[event_type].append(listener)
|
||||
|
||||
except KeyError: # event_type did not exist
|
||||
self._event_listeners[event_type] = [listener]
|
||||
|
||||
def listen_once_event(self, event_type, listener):
|
||||
""" Listen once for event of a specific type.
|
||||
|
||||
To listen to all events specify the constant ``MATCH_ALL``
|
||||
as event_type.
|
||||
|
||||
Note: at the moment it is impossible to remove a one time listener.
|
||||
"""
|
||||
@ft.wraps(listener)
|
||||
def onetime_listener(event):
|
||||
""" Removes listener from eventbus and then fires listener. """
|
||||
if not hasattr(onetime_listener, 'run'):
|
||||
# Set variable so that we will never run twice.
|
||||
# Because the event bus might have to wait till a thread comes
|
||||
# available to execute this listener it might occur that the
|
||||
# listener gets lined up twice to be executed.
|
||||
# This will make sure the second time it does nothing.
|
||||
onetime_listener.run = True
|
||||
|
||||
self.remove_event_listener(event_type, onetime_listener)
|
||||
|
||||
listener(event)
|
||||
|
||||
self.listen_event(event_type, onetime_listener)
|
||||
|
||||
def remove_event_listener(self, event_type, listener):
|
||||
""" Removes a listener of a specific event_type. """
|
||||
with self.event_lock:
|
||||
try:
|
||||
self._event_listeners[event_type].remove(listener)
|
||||
|
||||
# delete event_type list if empty
|
||||
if not self._event_listeners[event_type]:
|
||||
self._event_listeners.pop(event_type)
|
||||
|
||||
except (KeyError, AttributeError):
|
||||
# KeyError is key event_type listener did not exist
|
||||
# AttributeError if listener did not exist within event_type
|
||||
pass
|
||||
|
||||
def _check_busy(self):
|
||||
""" Complain if we have more than twice as many jobs queued as threads
|
||||
and if we didn't complain about it recently. """
|
||||
if self.pool.queue.qsize() / self.thread_count >= 2 and \
|
||||
dt.datetime.now()-self.last_busy_notice > BUS_REPORT_BUSY_TIMEOUT:
|
||||
|
||||
self.last_busy_notice = dt.datetime.now()
|
||||
|
||||
log_error = self.logger.error
|
||||
|
||||
log_error(
|
||||
u"Bus:All {} threads are busy and {} jobs pending".format(
|
||||
self.thread_count, self.pool.queue.qsize()))
|
||||
|
||||
jobs = self.pool.current_jobs
|
||||
|
||||
for start, job in jobs:
|
||||
log_error(u"Bus:Current job from {}: {}".format(
|
||||
util.datetime_to_str(start), job))
|
||||
|
||||
|
||||
class State(object):
|
||||
""" Object to represent a state within the state machine. """
|
||||
|
||||
__slots__ = ['entity_id', 'state', 'attributes', 'last_changed']
|
||||
|
||||
def __init__(self, entity_id, state, attributes=None, last_changed=None):
|
||||
self.entity_id = entity_id
|
||||
self.state = state
|
||||
self.attributes = attributes or {}
|
||||
last_changed = last_changed or dt.datetime.now()
|
||||
|
||||
# Strip microsecond from last_changed else we cannot guarantee
|
||||
# state == State.from_dict(state.as_dict())
|
||||
# This behavior occurs because to_dict uses datetime_to_str
|
||||
# which strips microseconds
|
||||
if last_changed.microsecond:
|
||||
self.last_changed = last_changed - dt.timedelta(
|
||||
microseconds=last_changed.microsecond)
|
||||
else:
|
||||
self.last_changed = last_changed
|
||||
|
||||
def copy(self):
|
||||
""" Creates a copy of itself. """
|
||||
return State(self.entity_id, self.state,
|
||||
dict(self.attributes), self.last_changed)
|
||||
|
||||
def as_dict(self):
|
||||
""" Converts State to a dict to be used within JSON.
|
||||
Ensures: state == State.from_dict(state.as_dict()) """
|
||||
|
||||
return {'entity_id': self.entity_id,
|
||||
'state': self.state,
|
||||
'attributes': self.attributes,
|
||||
'last_changed': util.datetime_to_str(self.last_changed)}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(json_dict):
|
||||
""" Static method to create a state from a dict.
|
||||
Ensures: state == State.from_json_dict(state.to_json_dict()) """
|
||||
|
||||
try:
|
||||
last_changed = json_dict.get('last_changed')
|
||||
|
||||
if last_changed:
|
||||
last_changed = util.str_to_datetime(last_changed)
|
||||
|
||||
return State(json_dict['entity_id'],
|
||||
json_dict['state'],
|
||||
json_dict.get('attributes'),
|
||||
last_changed)
|
||||
except KeyError: # if key 'entity_id' or 'state' did not exist
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
if self.attributes:
|
||||
return u"<state {}:{} @ {}>".format(
|
||||
self.state, util.repr_helper(self.attributes),
|
||||
util.datetime_to_str(self.last_changed))
|
||||
else:
|
||||
return u"<state {} @ {}>".format(
|
||||
self.state, util.datetime_to_str(self.last_changed))
|
||||
|
||||
|
||||
class StateMachine(object):
|
||||
""" Helper class that tracks the state of different entities. """
|
||||
|
||||
def __init__(self, bus):
|
||||
self.states = {}
|
||||
self.bus = bus
|
||||
self.lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def entity_ids(self):
|
||||
""" List of entitie ids that are being tracked. """
|
||||
with self.lock:
|
||||
return self.states.keys()
|
||||
|
||||
def remove_entity(self, entity_id):
|
||||
""" Removes a entity from the state machine.
|
||||
|
||||
Returns boolean to indicate if a entity was removed. """
|
||||
with self.lock:
|
||||
try:
|
||||
del self.states[entity_id]
|
||||
|
||||
return True
|
||||
|
||||
except KeyError:
|
||||
# if entity does not exist
|
||||
return False
|
||||
|
||||
def set_state(self, entity_id, new_state, attributes=None):
|
||||
""" Set the state of an entity, add entity if it does not exist.
|
||||
|
||||
Attributes is an optional dict to specify attributes of this state. """
|
||||
|
||||
attributes = attributes or {}
|
||||
|
||||
with self.lock:
|
||||
# Change state and fire listeners
|
||||
try:
|
||||
old_state = self.states[entity_id]
|
||||
|
||||
except KeyError:
|
||||
# If state did not exist yet
|
||||
self.states[entity_id] = State(entity_id, new_state,
|
||||
attributes)
|
||||
|
||||
else:
|
||||
if old_state.state != new_state or \
|
||||
old_state.attributes != attributes:
|
||||
|
||||
state = self.states[entity_id] = \
|
||||
State(entity_id, new_state, attributes)
|
||||
|
||||
self.bus.fire_event(EVENT_STATE_CHANGED,
|
||||
{'entity_id': entity_id,
|
||||
'old_state': old_state,
|
||||
'new_state': state})
|
||||
|
||||
def get_state(self, entity_id):
|
||||
""" Returns the state of the specified entity. """
|
||||
with self.lock:
|
||||
try:
|
||||
# Make a copy so people won't mutate the state
|
||||
return self.states[entity_id].copy()
|
||||
|
||||
except KeyError:
|
||||
# If entity does not exist
|
||||
return None
|
||||
|
||||
def is_state(self, entity_id, state):
|
||||
""" Returns True if entity exists and is specified state. """
|
||||
try:
|
||||
return self.get_state(entity_id).state == state
|
||||
except AttributeError:
|
||||
# get_state returned None
|
||||
return False
|
||||
|
||||
|
||||
class Timer(threading.Thread):
|
||||
""" Timer will sent out an event every TIMER_INTERVAL seconds. """
|
||||
|
||||
def __init__(self, bus):
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
self.daemon = True
|
||||
self.bus = bus
|
||||
|
||||
bus.listen_once_event(EVENT_HOMEASSISTANT_START,
|
||||
lambda event: self.start())
|
||||
|
||||
def run(self):
|
||||
""" Start the timer. """
|
||||
|
||||
logging.getLogger(__name__).info("Timer:starting")
|
||||
|
||||
last_fired_on_second = -1
|
||||
|
||||
calc_now = dt.datetime.now
|
||||
|
||||
while True:
|
||||
now = calc_now()
|
||||
|
||||
# First check checks if we are not on a second matching the
|
||||
# timer interval. Second check checks if we did not already fire
|
||||
# this interval.
|
||||
if now.second % TIMER_INTERVAL or \
|
||||
now.second == last_fired_on_second:
|
||||
|
||||
# Sleep till it is the next time that we have to fire an event.
|
||||
# Aim for halfway through the second that fits TIMER_INTERVAL.
|
||||
# If TIMER_INTERVAL is 10 fire at .5, 10.5, 20.5, etc seconds.
|
||||
# This will yield the best results because time.sleep() is not
|
||||
# 100% accurate because of non-realtime OS's
|
||||
slp_seconds = TIMER_INTERVAL - now.second % TIMER_INTERVAL + \
|
||||
.5 - now.microsecond/1000000.0
|
||||
|
||||
time.sleep(slp_seconds)
|
||||
|
||||
now = calc_now()
|
||||
|
||||
last_fired_on_second = now.second
|
||||
|
||||
self.bus.fire_event(EVENT_TIME_CHANGED,
|
||||
{'now': now})
|
||||
|
||||
|
||||
class HomeAssistantError(Exception):
|
||||
""" General Home Assistant exception occured. """
|
||||
|
||||
|
||||
class ServiceDoesNotExistError(HomeAssistantError):
|
||||
""" A service has been referenced that deos not exist. """
|
||||
|
||||
201
homeassistant/__main__.py
Normal file
@@ -0,0 +1,201 @@
|
||||
""" Starts home assistant. """
|
||||
from __future__ import print_function
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
from homeassistant import bootstrap
|
||||
import homeassistant.config as config_util
|
||||
from homeassistant.const import __version__, EVENT_HOMEASSISTANT_START
|
||||
|
||||
|
||||
def validate_python():
|
||||
""" Validate we're running the right Python version. """
|
||||
major, minor = sys.version_info[:2]
|
||||
|
||||
if major < 3 or (major == 3 and minor < 4):
|
||||
print("Home Assistant requires atleast Python 3.4")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_config_path(config_dir):
|
||||
""" Validates configuration directory. """
|
||||
|
||||
lib_dir = os.path.join(config_dir, 'lib')
|
||||
|
||||
# Test if configuration directory exists
|
||||
if not os.path.isdir(config_dir):
|
||||
if config_dir != config_util.get_default_config_dir():
|
||||
print(('Fatal Error: Specified configuration directory does '
|
||||
'not exist {} ').format(config_dir))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
os.mkdir(config_dir)
|
||||
except OSError:
|
||||
print(('Fatal Error: Unable to create default configuration '
|
||||
'directory {} ').format(config_dir))
|
||||
sys.exit(1)
|
||||
|
||||
# Test if library directory exists
|
||||
if not os.path.isdir(lib_dir):
|
||||
try:
|
||||
os.mkdir(lib_dir)
|
||||
except OSError:
|
||||
print(('Fatal Error: Unable to create library '
|
||||
'directory {} ').format(lib_dir))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_config_file(config_dir):
|
||||
""" Ensure configuration file exists. """
|
||||
config_path = config_util.ensure_config_exists(config_dir)
|
||||
|
||||
if config_path is None:
|
||||
print('Error getting configuration path')
|
||||
sys.exit(1)
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
def get_arguments():
|
||||
""" Get parsed passed in arguments. """
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Home Assistant: Observe, Control, Automate.")
|
||||
parser.add_argument('--version', action='version', version=__version__)
|
||||
parser.add_argument(
|
||||
'-c', '--config',
|
||||
metavar='path_to_config_dir',
|
||||
default=config_util.get_default_config_dir(),
|
||||
help="Directory that contains the Home Assistant configuration")
|
||||
parser.add_argument(
|
||||
'--demo-mode',
|
||||
action='store_true',
|
||||
help='Start Home Assistant in demo mode')
|
||||
parser.add_argument(
|
||||
'--open-ui',
|
||||
action='store_true',
|
||||
help='Open the webinterface in a browser')
|
||||
parser.add_argument(
|
||||
'--skip-pip',
|
||||
action='store_true',
|
||||
help='Skips pip install of required packages on startup')
|
||||
parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
action='store_true',
|
||||
help="Enable verbose logging to file.")
|
||||
parser.add_argument(
|
||||
'--pid-file',
|
||||
metavar='path_to_pid_file',
|
||||
default=None,
|
||||
help='Path to PID file useful for running as daemon')
|
||||
parser.add_argument(
|
||||
'--log-rotate-days',
|
||||
type=int,
|
||||
default=None,
|
||||
help='Enables daily log rotation and keeps up to the specified days')
|
||||
if os.name != "nt":
|
||||
parser.add_argument(
|
||||
'--daemon',
|
||||
action='store_true',
|
||||
help='Run Home Assistant as daemon')
|
||||
|
||||
arguments = parser.parse_args()
|
||||
if os.name == "nt":
|
||||
arguments.daemon = False
|
||||
return arguments
|
||||
|
||||
|
||||
def daemonize():
|
||||
""" Move current process to daemon process """
|
||||
# create first fork
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
|
||||
# decouple fork
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# create second fork
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def check_pid(pid_file):
|
||||
""" Check that HA is not already running """
|
||||
# check pid file
|
||||
try:
|
||||
pid = int(open(pid_file, 'r').readline())
|
||||
except IOError:
|
||||
# PID File does not exist
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
# PID does not exist
|
||||
return
|
||||
print('Fatal Error: HomeAssistant is already running.')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def write_pid(pid_file):
|
||||
""" Create PID File """
|
||||
pid = os.getpid()
|
||||
try:
|
||||
open(pid_file, 'w').write(str(pid))
|
||||
except IOError:
|
||||
print('Fatal Error: Unable to write pid file {}'.format(pid_file))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
""" Starts Home Assistant. """
|
||||
validate_python()
|
||||
|
||||
args = get_arguments()
|
||||
|
||||
config_dir = os.path.join(os.getcwd(), args.config)
|
||||
ensure_config_path(config_dir)
|
||||
|
||||
# daemon functions
|
||||
if args.pid_file:
|
||||
check_pid(args.pid_file)
|
||||
if args.daemon:
|
||||
daemonize()
|
||||
if args.pid_file:
|
||||
write_pid(args.pid_file)
|
||||
|
||||
if args.demo_mode:
|
||||
config = {
|
||||
'frontend': {},
|
||||
'demo': {}
|
||||
}
|
||||
hass = bootstrap.from_config_dict(
|
||||
config, config_dir=config_dir, daemon=args.daemon,
|
||||
verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days)
|
||||
else:
|
||||
config_file = ensure_config_file(config_dir)
|
||||
print('Config directory:', config_dir)
|
||||
hass = bootstrap.from_config_file(
|
||||
config_file, daemon=args.daemon, verbose=args.verbose,
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days)
|
||||
|
||||
if args.open_ui:
|
||||
def open_browser(event):
|
||||
""" Open the webinterface in a browser. """
|
||||
if hass.config.api is not None:
|
||||
import webbrowser
|
||||
webbrowser.open(hass.config.api.base_url)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, open_browser)
|
||||
|
||||
hass.start()
|
||||
hass.block_till_stopped()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,199 +1,356 @@
|
||||
"""
|
||||
homeassistant.bootstrap
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides methods to bootstrap a home assistant instance.
|
||||
|
||||
Each method will return a tuple (bus, statemachine).
|
||||
|
||||
After bootstrapping you can add your own components or
|
||||
start by calling homeassistant.start_home_assistant(bus)
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import ConfigParser
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import logging.handlers
|
||||
from collections import defaultdict
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.components as components
|
||||
import homeassistant.core as core
|
||||
import homeassistant.util.dt as date_util
|
||||
import homeassistant.util.package as pkg_util
|
||||
import homeassistant.util.location as loc_util
|
||||
import homeassistant.config as config_util
|
||||
import homeassistant.loader as loader
|
||||
import homeassistant.components as core_components
|
||||
import homeassistant.components.group as group
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.const import (
|
||||
EVENT_COMPONENT_LOADED, CONF_LATITUDE, CONF_LONGITUDE,
|
||||
CONF_TEMPERATURE_UNIT, CONF_NAME, CONF_TIME_ZONE, CONF_CUSTOMIZE,
|
||||
TEMP_CELCIUS, TEMP_FAHRENHEIT)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_COMPONENT = 'component'
|
||||
|
||||
PLATFORM_FORMAT = '{}.{}'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
|
||||
def from_config_file(config_path):
|
||||
""" Starts home assistant with all possible functionality
|
||||
based on a config file. """
|
||||
def setup_component(hass, domain, config=None):
|
||||
""" Setup a component and all its dependencies. """
|
||||
|
||||
# Setup the logging for home assistant.
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
if domain in hass.config.components:
|
||||
return True
|
||||
|
||||
# Log errors to a file
|
||||
err_handler = logging.FileHandler("home-assistant.log",
|
||||
mode='w', delay=True)
|
||||
err_handler.setLevel(logging.ERROR)
|
||||
err_handler.setFormatter(
|
||||
logging.Formatter('%(asctime)s %(name)s: %(message)s',
|
||||
datefmt='%H:%M %d-%m-%y'))
|
||||
logging.getLogger('').addHandler(err_handler)
|
||||
_ensure_loader_prepared(hass)
|
||||
|
||||
# Start the actual bootstrapping
|
||||
logger = logging.getLogger(__name__)
|
||||
if config is None:
|
||||
config = defaultdict(dict)
|
||||
|
||||
statusses = []
|
||||
components = loader.load_order_component(domain)
|
||||
|
||||
# Read config
|
||||
config = ConfigParser.SafeConfigParser()
|
||||
config.read(config_path)
|
||||
# OrderedSet is empty if component or dependencies could not be resolved
|
||||
if not components:
|
||||
return False
|
||||
|
||||
# Init core
|
||||
bus = ha.Bus()
|
||||
statemachine = ha.StateMachine(bus)
|
||||
for component in components:
|
||||
if not _setup_component(hass, component, config):
|
||||
return False
|
||||
|
||||
has_opt = config.has_option
|
||||
get_opt = config.get
|
||||
has_section = config.has_section
|
||||
add_status = lambda name, result: statusses.append((name, result))
|
||||
load_module = lambda module: importlib.import_module(
|
||||
'homeassistant.components.'+module)
|
||||
return True
|
||||
|
||||
def get_opt_safe(section, option, default=None):
|
||||
""" Failure proof option retriever. """
|
||||
try:
|
||||
return config.get(section, option)
|
||||
except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
|
||||
return default
|
||||
|
||||
# Device scanner
|
||||
dev_scan = None
|
||||
def _handle_requirements(hass, component, name):
|
||||
""" Installs requirements for component. """
|
||||
if hass.config.skip_pip or not hasattr(component, 'REQUIREMENTS'):
|
||||
return True
|
||||
|
||||
for req in component.REQUIREMENTS:
|
||||
if not pkg_util.install_package(req, target=hass.config.path('lib')):
|
||||
_LOGGER.error('Not initializing %s because could not install '
|
||||
'dependency %s', name, req)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _setup_component(hass, domain, config):
|
||||
""" Setup a component for Home Assistant. """
|
||||
if domain in hass.config.components:
|
||||
return True
|
||||
component = loader.get_component(domain)
|
||||
|
||||
missing_deps = [dep for dep in component.DEPENDENCIES
|
||||
if dep not in hass.config.components]
|
||||
|
||||
if missing_deps:
|
||||
_LOGGER.error(
|
||||
'Not initializing %s because not all dependencies loaded: %s',
|
||||
domain, ", ".join(missing_deps))
|
||||
return False
|
||||
|
||||
if not _handle_requirements(hass, component, domain):
|
||||
return False
|
||||
|
||||
try:
|
||||
# For the error message if not all option fields exist
|
||||
opt_fields = "host, username, password"
|
||||
if not component.setup(hass, config):
|
||||
_LOGGER.error('component %s failed to initialize', domain)
|
||||
return False
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception('Error during setup of component %s', domain)
|
||||
return False
|
||||
|
||||
if has_section('device_tracker.tomato'):
|
||||
device_tracker = load_module('device_tracker')
|
||||
hass.config.components.append(component.DOMAIN)
|
||||
|
||||
dev_scan_name = "Tomato"
|
||||
opt_fields += ", http_id"
|
||||
# Assumption: if a component does not depend on groups
|
||||
# it communicates with devices
|
||||
if group.DOMAIN not in component.DEPENDENCIES:
|
||||
hass.pool.add_worker()
|
||||
|
||||
dev_scan = device_tracker.TomatoDeviceScanner(
|
||||
get_opt('device_tracker.tomato', 'host'),
|
||||
get_opt('device_tracker.tomato', 'username'),
|
||||
get_opt('device_tracker.tomato', 'password'),
|
||||
get_opt('device_tracker.tomato', 'http_id'))
|
||||
hass.bus.fire(
|
||||
EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN})
|
||||
|
||||
elif has_section('device_tracker.netgear'):
|
||||
device_tracker = load_module('device_tracker')
|
||||
return True
|
||||
|
||||
dev_scan_name = "Netgear"
|
||||
|
||||
dev_scan = device_tracker.NetgearDeviceScanner(
|
||||
get_opt('device_tracker.netgear', 'host'),
|
||||
get_opt('device_tracker.netgear', 'username'),
|
||||
get_opt('device_tracker.netgear', 'password'))
|
||||
def prepare_setup_platform(hass, config, domain, platform_name):
|
||||
""" Loads a platform and makes sure dependencies are setup. """
|
||||
_ensure_loader_prepared(hass)
|
||||
|
||||
except ConfigParser.NoOptionError:
|
||||
# If one of the options didn't exist
|
||||
logger.exception(("Error initializing {}DeviceScanner, "
|
||||
"could not find one of the following config "
|
||||
"options: {}".format(dev_scan_name, opt_fields)))
|
||||
platform_path = PLATFORM_FORMAT.format(domain, platform_name)
|
||||
|
||||
add_status("Device Scanner - {}".format(dev_scan_name), False)
|
||||
platform = loader.get_component(platform_path)
|
||||
|
||||
if dev_scan:
|
||||
add_status("Device Scanner - {}".format(dev_scan_name),
|
||||
dev_scan.success_init)
|
||||
# Not found
|
||||
if platform is None:
|
||||
return None
|
||||
|
||||
if not dev_scan.success_init:
|
||||
dev_scan = None
|
||||
# Already loaded
|
||||
elif platform_path in hass.config.components:
|
||||
return platform
|
||||
|
||||
# Device Tracker
|
||||
if dev_scan:
|
||||
device_tracker.DeviceTracker(bus, statemachine, dev_scan)
|
||||
# Load dependencies
|
||||
if hasattr(platform, 'DEPENDENCIES'):
|
||||
for component in platform.DEPENDENCIES:
|
||||
if not setup_component(hass, component, config):
|
||||
_LOGGER.error(
|
||||
'Unable to prepare setup for platform %s because '
|
||||
'dependency %s could not be initialized', platform_path,
|
||||
component)
|
||||
return None
|
||||
|
||||
add_status("Device Tracker", True)
|
||||
if not _handle_requirements(hass, platform, platform_path):
|
||||
return None
|
||||
|
||||
# Sun tracker
|
||||
if has_opt("common", "latitude") and \
|
||||
has_opt("common", "longitude"):
|
||||
return platform
|
||||
|
||||
sun = load_module('sun')
|
||||
|
||||
add_status("Weather - Ephem",
|
||||
sun.setup(
|
||||
bus, statemachine,
|
||||
get_opt("common", "latitude"),
|
||||
get_opt("common", "longitude")))
|
||||
def mount_local_lib_path(config_dir):
|
||||
""" Add local library to Python Path """
|
||||
sys.path.insert(0, os.path.join(config_dir, 'lib'))
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-statements, too-many-arguments
|
||||
def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
|
||||
verbose=False, daemon=False, skip_pip=False,
|
||||
log_rotate_days=None):
|
||||
"""
|
||||
Tries to configure Home Assistant from a config dict.
|
||||
|
||||
Dynamically loads required components and its dependencies.
|
||||
"""
|
||||
if hass is None:
|
||||
hass = core.HomeAssistant()
|
||||
if config_dir is not None:
|
||||
config_dir = os.path.abspath(config_dir)
|
||||
hass.config.config_dir = config_dir
|
||||
mount_local_lib_path(config_dir)
|
||||
|
||||
process_ha_core_config(hass, config.get(core.DOMAIN, {}))
|
||||
|
||||
if enable_log:
|
||||
enable_logging(hass, verbose, daemon, log_rotate_days)
|
||||
|
||||
hass.config.skip_pip = skip_pip
|
||||
if skip_pip:
|
||||
_LOGGER.warning('Skipping pip installation of required modules. '
|
||||
'This may cause issues.')
|
||||
|
||||
_ensure_loader_prepared(hass)
|
||||
|
||||
# Make a copy because we are mutating it.
|
||||
# Convert it to defaultdict so components can always have config dict
|
||||
# Convert values to dictionaries if they are None
|
||||
config = defaultdict(
|
||||
dict, {key: value or {} for key, value in config.items()})
|
||||
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
components = (key for key in config.keys()
|
||||
if ' ' not in key and key != core.DOMAIN)
|
||||
|
||||
if not core_components.setup(hass, config):
|
||||
_LOGGER.error('Home Assistant core failed to initialize. '
|
||||
'Further initialization aborted.')
|
||||
|
||||
return hass
|
||||
|
||||
_LOGGER.info('Home Assistant core initialized')
|
||||
|
||||
# Setup the components
|
||||
for domain in loader.load_order_components(components):
|
||||
_setup_component(hass, domain, config)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
def from_config_file(config_path, hass=None, verbose=False, daemon=False,
|
||||
skip_pip=True, log_rotate_days=None):
|
||||
"""
|
||||
Reads the configuration file and tries to start all the required
|
||||
functionality. Will add functionality to 'hass' parameter if given,
|
||||
instantiates a new Home Assistant object if 'hass' is not given.
|
||||
"""
|
||||
if hass is None:
|
||||
hass = core.HomeAssistant()
|
||||
|
||||
# Set config dir to directory holding config file
|
||||
config_dir = os.path.abspath(os.path.dirname(config_path))
|
||||
hass.config.config_dir = config_dir
|
||||
mount_local_lib_path(config_dir)
|
||||
|
||||
enable_logging(hass, verbose, daemon, log_rotate_days)
|
||||
|
||||
config_dict = config_util.load_config_file(config_path)
|
||||
|
||||
return from_config_dict(config_dict, hass, enable_log=False,
|
||||
skip_pip=skip_pip)
|
||||
|
||||
|
||||
def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None):
|
||||
""" Setup the logging for home assistant. """
|
||||
if not daemon:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) "
|
||||
"[%(name)s] %(message)s%(reset)s")
|
||||
try:
|
||||
from colorlog import ColoredFormatter
|
||||
logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
|
||||
fmt,
|
||||
datefmt='%y-%m-%d %H:%M:%S',
|
||||
reset=True,
|
||||
log_colors={
|
||||
'DEBUG': 'cyan',
|
||||
'INFO': 'green',
|
||||
'WARNING': 'yellow',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'red',
|
||||
}
|
||||
))
|
||||
except ImportError:
|
||||
_LOGGER.warning(
|
||||
"Colorlog package not found, console coloring disabled")
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
err_log_path = hass.config.path('home-assistant.log')
|
||||
err_path_exists = os.path.isfile(err_log_path)
|
||||
|
||||
# Check if we can write to the error log if it exists or that
|
||||
# we can create files in the containing directory if not.
|
||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or \
|
||||
(not err_path_exists and os.access(hass.config.config_dir, os.W_OK)):
|
||||
|
||||
if log_rotate_days:
|
||||
err_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
err_log_path, when='midnight', backupCount=log_rotate_days)
|
||||
else:
|
||||
err_handler = logging.FileHandler(
|
||||
err_log_path, mode='w', delay=True)
|
||||
|
||||
err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||
err_handler.setFormatter(
|
||||
logging.Formatter('%(asctime)s %(name)s: %(message)s',
|
||||
datefmt='%y-%m-%d %H:%M:%S'))
|
||||
logger = logging.getLogger('')
|
||||
logger.addHandler(err_handler)
|
||||
logger.setLevel(logging.INFO) # this sets the minimum log level
|
||||
|
||||
else:
|
||||
sun = None
|
||||
_LOGGER.error(
|
||||
'Unable to setup error log %s (access denied)', err_log_path)
|
||||
|
||||
# Chromecast
|
||||
if has_section("chromecast"):
|
||||
chromecast = load_module('chromecast')
|
||||
|
||||
chromecast_started = chromecast.setup(bus, statemachine)
|
||||
def process_ha_core_config(hass, config):
|
||||
""" Processes the [homeassistant] section from the config. """
|
||||
hac = hass.config
|
||||
|
||||
add_status("Chromecast", chromecast_started)
|
||||
else:
|
||||
chromecast_started = False
|
||||
def set_time_zone(time_zone_str):
|
||||
""" Helper method to set time zone in HA. """
|
||||
if time_zone_str is None:
|
||||
return
|
||||
|
||||
# WeMo
|
||||
if has_section("wemo"):
|
||||
wemo = load_module('wemo')
|
||||
time_zone = date_util.get_time_zone(time_zone_str)
|
||||
|
||||
add_status("WeMo", wemo.setup(bus, statemachine))
|
||||
if time_zone:
|
||||
hac.time_zone = time_zone
|
||||
date_util.set_default_time_zone(time_zone)
|
||||
else:
|
||||
_LOGGER.error('Received invalid time zone %s', time_zone_str)
|
||||
|
||||
# Light control
|
||||
if has_section("light.hue"):
|
||||
light = load_module('light')
|
||||
for key, attr in ((CONF_LATITUDE, 'latitude'),
|
||||
(CONF_LONGITUDE, 'longitude'),
|
||||
(CONF_NAME, 'location_name')):
|
||||
if key in config:
|
||||
setattr(hac, attr, config[key])
|
||||
|
||||
light_control = light.HueLightControl(get_opt_safe("hue", "host"))
|
||||
set_time_zone(config.get(CONF_TIME_ZONE))
|
||||
|
||||
add_status("Light - Hue", light_control.success_init)
|
||||
customize = config.get(CONF_CUSTOMIZE)
|
||||
|
||||
light.setup(bus, statemachine, light_control)
|
||||
else:
|
||||
light_control = None
|
||||
if isinstance(customize, dict):
|
||||
for entity_id, attrs in config.get(CONF_CUSTOMIZE, {}).items():
|
||||
if not isinstance(attrs, dict):
|
||||
continue
|
||||
Entity.overwrite_attribute(entity_id, attrs.keys(), attrs.values())
|
||||
|
||||
if has_opt("downloader", "download_dir"):
|
||||
downloader = load_module('downloader')
|
||||
if CONF_TEMPERATURE_UNIT in config:
|
||||
unit = config[CONF_TEMPERATURE_UNIT]
|
||||
|
||||
add_status("Downloader", downloader.setup(
|
||||
bus, get_opt("downloader", "download_dir")))
|
||||
if unit == 'C':
|
||||
hac.temperature_unit = TEMP_CELCIUS
|
||||
elif unit == 'F':
|
||||
hac.temperature_unit = TEMP_FAHRENHEIT
|
||||
|
||||
add_status("Core components", components.setup(bus, statemachine))
|
||||
# If we miss some of the needed values, auto detect them
|
||||
if None not in (
|
||||
hac.latitude, hac.longitude, hac.temperature_unit, hac.time_zone):
|
||||
return
|
||||
|
||||
if has_section('browser'):
|
||||
add_status("Browser", load_module('browser').setup(bus))
|
||||
_LOGGER.info('Auto detecting location and temperature unit')
|
||||
|
||||
if has_section('keyboard'):
|
||||
add_status("Keyboard", load_module('keyboard').setup(bus))
|
||||
info = loc_util.detect_location_info()
|
||||
|
||||
# Init HTTP interface
|
||||
if has_opt("httpinterface", "api_password"):
|
||||
httpinterface = load_module('httpinterface')
|
||||
if info is None:
|
||||
_LOGGER.error('Could not detect location information')
|
||||
return
|
||||
|
||||
httpinterface.HTTPInterface(
|
||||
bus, statemachine,
|
||||
get_opt("httpinterface", "api_password"))
|
||||
if hac.latitude is None and hac.longitude is None:
|
||||
hac.latitude = info.latitude
|
||||
hac.longitude = info.longitude
|
||||
|
||||
add_status("HTTPInterface", True)
|
||||
if hac.temperature_unit is None:
|
||||
if info.use_fahrenheit:
|
||||
hac.temperature_unit = TEMP_FAHRENHEIT
|
||||
else:
|
||||
hac.temperature_unit = TEMP_CELCIUS
|
||||
|
||||
# Init groups
|
||||
if has_section("group"):
|
||||
group = load_module('group')
|
||||
if hac.location_name is None:
|
||||
hac.location_name = info.city
|
||||
|
||||
for name, entity_ids in config.items("group"):
|
||||
add_status("Group - {}".format(name),
|
||||
group.setup(bus, statemachine, name,
|
||||
entity_ids.split(",")))
|
||||
if hac.time_zone is None:
|
||||
set_time_zone(info.time_zone)
|
||||
|
||||
# Light trigger
|
||||
if light_control and sun:
|
||||
device_sun_light_trigger = load_module('device_sun_light_trigger')
|
||||
|
||||
light_group = get_opt_safe("device_sun_light_trigger", "light_group")
|
||||
light_profile = get_opt_safe("device_sun_light_trigger",
|
||||
"light_profile")
|
||||
|
||||
add_status("Device Sun Light Trigger",
|
||||
device_sun_light_trigger.setup(bus, statemachine,
|
||||
light_group, light_profile))
|
||||
|
||||
for component, success_init in statusses:
|
||||
status = "initialized" if success_init else "Failed to initialize"
|
||||
|
||||
logger.info("{}: {}".format(component, status))
|
||||
|
||||
ha.start_home_assistant(bus)
|
||||
def _ensure_loader_prepared(hass):
|
||||
""" Ensure Home Assistant loader is prepared. """
|
||||
if not loader.PREPARED:
|
||||
loader.prepare(hass)
|
||||
|
||||
@@ -15,128 +15,73 @@ Each component should publish services only under its own domain.
|
||||
|
||||
"""
|
||||
import itertools as it
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.util as util
|
||||
from homeassistant.helpers import extract_entity_ids
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF)
|
||||
|
||||
# Contains one string or a list of strings, each being an entity id
|
||||
ATTR_ENTITY_ID = 'entity_id'
|
||||
|
||||
# String with a friendly name for the entity
|
||||
ATTR_FRIENDLY_NAME = "friendly_name"
|
||||
|
||||
STATE_ON = 'on'
|
||||
STATE_OFF = 'off'
|
||||
STATE_NOT_HOME = 'not_home'
|
||||
STATE_HOME = 'home'
|
||||
|
||||
SERVICE_TURN_ON = 'turn_on'
|
||||
SERVICE_TURN_OFF = 'turn_off'
|
||||
|
||||
SERVICE_VOLUME_UP = "volume_up"
|
||||
SERVICE_VOLUME_DOWN = "volume_down"
|
||||
SERVICE_VOLUME_MUTE = "volume_mute"
|
||||
SERVICE_MEDIA_PLAY_PAUSE = "media_play_pause"
|
||||
SERVICE_MEDIA_NEXT_TRACK = "media_next_track"
|
||||
SERVICE_MEDIA_PREV_TRACK = "media_prev_track"
|
||||
|
||||
_LOADED_COMP = {}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_component(component):
|
||||
""" Returns requested component. Imports it if necessary. """
|
||||
|
||||
comps = _LOADED_COMP
|
||||
|
||||
# See if we have the module locally cached, else import it
|
||||
try:
|
||||
return comps[component]
|
||||
|
||||
except KeyError:
|
||||
# If comps[component] does not exist, import module
|
||||
try:
|
||||
comps[component] = importlib.import_module(
|
||||
'homeassistant.components.'+component)
|
||||
|
||||
except ImportError:
|
||||
# If we got a bogus component the input will fail
|
||||
comps[component] = None
|
||||
|
||||
return comps[component]
|
||||
|
||||
|
||||
def is_on(statemachine, entity_id=None):
|
||||
def is_on(hass, entity_id=None):
|
||||
""" Loads up the module to call the is_on method.
|
||||
If there is no entity id given we will check all. """
|
||||
if entity_id:
|
||||
group = _get_component('group')
|
||||
group = get_component('group')
|
||||
|
||||
entity_ids = group.expand_entity_ids([entity_id])
|
||||
entity_ids = group.expand_entity_ids(hass, [entity_id])
|
||||
else:
|
||||
entity_ids = statemachine.entity_ids
|
||||
entity_ids = hass.states.entity_ids()
|
||||
|
||||
for entity_id in entity_ids:
|
||||
domain = util.split_entity_id(entity_id)[0]
|
||||
|
||||
module = _get_component(domain)
|
||||
module = get_component(domain)
|
||||
|
||||
try:
|
||||
if module.is_on(statemachine, entity_id):
|
||||
if module.is_on(hass, entity_id):
|
||||
return True
|
||||
|
||||
except AttributeError:
|
||||
# module is None or method is_on does not exist
|
||||
pass
|
||||
_LOGGER.exception("Failed to call %s.is_on for %s",
|
||||
module, entity_id)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def turn_on(bus, **service_data):
|
||||
def turn_on(hass, entity_id=None, **service_data):
|
||||
""" Turns specified entity on if possible. """
|
||||
bus.call_service(ha.DOMAIN, SERVICE_TURN_ON, service_data)
|
||||
if entity_id is not None:
|
||||
service_data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(ha.DOMAIN, SERVICE_TURN_ON, service_data)
|
||||
|
||||
|
||||
def turn_off(bus, **service_data):
|
||||
def turn_off(hass, entity_id=None, **service_data):
|
||||
""" Turns specified entity off. """
|
||||
bus.call_service(ha.DOMAIN, SERVICE_TURN_OFF, service_data)
|
||||
if entity_id is not None:
|
||||
service_data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(ha.DOMAIN, SERVICE_TURN_OFF, service_data)
|
||||
|
||||
|
||||
def extract_entity_ids(statemachine, service):
|
||||
"""
|
||||
Helper method to extract a list of entity ids from a service call.
|
||||
Will convert group entity ids to the entity ids it represents.
|
||||
"""
|
||||
entity_ids = []
|
||||
|
||||
if service.data and ATTR_ENTITY_ID in service.data:
|
||||
group = _get_component('group')
|
||||
|
||||
# Entity ID attr can be a list or a string
|
||||
service_ent_id = service.data[ATTR_ENTITY_ID]
|
||||
if isinstance(service_ent_id, list):
|
||||
ent_ids = service_ent_id
|
||||
else:
|
||||
ent_ids = [service_ent_id]
|
||||
|
||||
entity_ids.extend(
|
||||
ent_id for ent_id
|
||||
in group.expand_entity_ids(statemachine, ent_ids)
|
||||
if ent_id not in entity_ids)
|
||||
|
||||
return entity_ids
|
||||
|
||||
|
||||
def setup(bus, statemachine):
|
||||
def setup(hass, config):
|
||||
""" Setup general services related to homeassistant. """
|
||||
|
||||
def handle_turn_service(service):
|
||||
""" Method to handle calls to homeassistant.turn_on/off. """
|
||||
|
||||
entity_ids = extract_entity_ids(statemachine, service)
|
||||
entity_ids = extract_entity_ids(hass, service)
|
||||
|
||||
# Generic turn on/off method requires entity id
|
||||
if not entity_ids:
|
||||
_LOGGER.error(
|
||||
"homeassistant/%s cannot be called without entity_id",
|
||||
service.service)
|
||||
return
|
||||
|
||||
# Group entity_ids by domain. groupby requires sorted data.
|
||||
@@ -150,13 +95,9 @@ def setup(bus, statemachine):
|
||||
# ent_ids is a generator, convert it to a list.
|
||||
data[ATTR_ENTITY_ID] = list(ent_ids)
|
||||
|
||||
try:
|
||||
bus.call_service(domain, service.service, data)
|
||||
except ha.ServiceDoesNotExistError:
|
||||
# turn_on service does not exist
|
||||
pass
|
||||
hass.services.call(domain, service.service, data, True)
|
||||
|
||||
bus.register_service(ha.DOMAIN, SERVICE_TURN_OFF, handle_turn_service)
|
||||
bus.register_service(ha.DOMAIN, SERVICE_TURN_ON, handle_turn_service)
|
||||
hass.services.register(ha.DOMAIN, SERVICE_TURN_OFF, handle_turn_service)
|
||||
hass.services.register(ha.DOMAIN, SERVICE_TURN_ON, handle_turn_service)
|
||||
|
||||
return True
|
||||
|
||||
346
homeassistant/components/api.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
homeassistant.components.api
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides a Rest API for Home Assistant.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
import threading
|
||||
import json
|
||||
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.helpers.state import TrackStates
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.const import (
|
||||
URL_API, URL_API_STATES, URL_API_EVENTS, URL_API_SERVICES, URL_API_STREAM,
|
||||
URL_API_EVENT_FORWARD, URL_API_STATES_ENTITY, URL_API_COMPONENTS,
|
||||
URL_API_CONFIG, URL_API_BOOTSTRAP,
|
||||
EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL,
|
||||
HTTP_OK, HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
|
||||
DOMAIN = 'api'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
STREAM_PING_PAYLOAD = "ping"
|
||||
STREAM_PING_INTERVAL = 50 # seconds
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Register the API with the HTTP interface. """
|
||||
|
||||
if 'http' not in hass.config.components:
|
||||
_LOGGER.error('Dependency http is not loaded')
|
||||
return False
|
||||
|
||||
# /api - for validation purposes
|
||||
hass.http.register_path('GET', URL_API, _handle_get_api)
|
||||
|
||||
# /api/stream
|
||||
hass.http.register_path('GET', URL_API_STREAM, _handle_get_api_stream)
|
||||
|
||||
# /api/config
|
||||
hass.http.register_path('GET', URL_API_CONFIG, _handle_get_api_config)
|
||||
|
||||
# /api/bootstrap
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_BOOTSTRAP, _handle_get_api_bootstrap)
|
||||
|
||||
# /states
|
||||
hass.http.register_path('GET', URL_API_STATES, _handle_get_api_states)
|
||||
hass.http.register_path(
|
||||
'GET', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_get_api_states_entity)
|
||||
hass.http.register_path(
|
||||
'POST', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_post_state_entity)
|
||||
hass.http.register_path(
|
||||
'PUT', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_post_state_entity)
|
||||
|
||||
# /events
|
||||
hass.http.register_path('GET', URL_API_EVENTS, _handle_get_api_events)
|
||||
hass.http.register_path(
|
||||
'POST', re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'),
|
||||
_handle_api_post_events_event)
|
||||
|
||||
# /services
|
||||
hass.http.register_path('GET', URL_API_SERVICES, _handle_get_api_services)
|
||||
hass.http.register_path(
|
||||
'POST',
|
||||
re.compile((r'/api/services/'
|
||||
r'(?P<domain>[a-zA-Z\._0-9]+)/'
|
||||
r'(?P<service>[a-zA-Z\._0-9]+)')),
|
||||
_handle_post_api_services_domain_service)
|
||||
|
||||
# /event_forwarding
|
||||
hass.http.register_path(
|
||||
'POST', URL_API_EVENT_FORWARD, _handle_post_api_event_forward)
|
||||
hass.http.register_path(
|
||||
'DELETE', URL_API_EVENT_FORWARD, _handle_delete_api_event_forward)
|
||||
|
||||
# /components
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_COMPONENTS, _handle_get_api_components)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_api(handler, path_match, data):
|
||||
""" Renders the debug interface. """
|
||||
handler.write_json_message("API running.")
|
||||
|
||||
|
||||
def _handle_get_api_stream(handler, path_match, data):
|
||||
""" Provide a streaming interface for the event bus. """
|
||||
gracefully_closed = False
|
||||
hass = handler.server.hass
|
||||
wfile = handler.wfile
|
||||
write_lock = threading.Lock()
|
||||
block = threading.Event()
|
||||
|
||||
def write_message(payload):
|
||||
""" Writes a message to the output. """
|
||||
with write_lock:
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
|
||||
try:
|
||||
wfile.write(msg.encode("UTF-8"))
|
||||
wfile.flush()
|
||||
except IOError:
|
||||
block.set()
|
||||
|
||||
def forward_events(event):
|
||||
""" Forwards events to the open request. """
|
||||
nonlocal gracefully_closed
|
||||
|
||||
if block.is_set() or event.event_type == EVENT_TIME_CHANGED:
|
||||
return
|
||||
elif event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
gracefully_closed = True
|
||||
block.set()
|
||||
return
|
||||
|
||||
write_message(json.dumps(event, cls=rem.JSONEncoder))
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header('Content-type', 'text/event-stream')
|
||||
handler.end_headers()
|
||||
|
||||
hass.bus.listen(MATCH_ALL, forward_events)
|
||||
|
||||
while True:
|
||||
write_message(STREAM_PING_PAYLOAD)
|
||||
|
||||
block.wait(STREAM_PING_INTERVAL)
|
||||
|
||||
if block.is_set():
|
||||
break
|
||||
|
||||
if not gracefully_closed:
|
||||
_LOGGER.info("Found broken event stream to %s, cleaning up",
|
||||
handler.client_address[0])
|
||||
|
||||
hass.bus.remove_listener(MATCH_ALL, forward_events)
|
||||
|
||||
|
||||
def _handle_get_api_config(handler, path_match, data):
|
||||
""" Returns the Home Assistant config. """
|
||||
handler.write_json(handler.server.hass.config.as_dict())
|
||||
|
||||
|
||||
def _handle_get_api_bootstrap(handler, path_match, data):
|
||||
""" Returns all data needed to bootstrap Home Assistant. """
|
||||
hass = handler.server.hass
|
||||
|
||||
handler.write_json({
|
||||
'config': hass.config.as_dict(),
|
||||
'states': hass.states.all(),
|
||||
'events': _events_json(hass),
|
||||
'services': _services_json(hass),
|
||||
})
|
||||
|
||||
|
||||
def _handle_get_api_states(handler, path_match, data):
|
||||
""" Returns a dict containing all entity ids and their state. """
|
||||
handler.write_json(handler.server.hass.states.all())
|
||||
|
||||
|
||||
def _handle_get_api_states_entity(handler, path_match, data):
|
||||
""" Returns the state of a specific entity. """
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
state = handler.server.hass.states.get(entity_id)
|
||||
|
||||
if state:
|
||||
handler.write_json(state)
|
||||
else:
|
||||
handler.write_json_message("State does not exist.", HTTP_NOT_FOUND)
|
||||
|
||||
|
||||
def _handle_post_state_entity(handler, path_match, data):
|
||||
""" Handles updating the state of an entity.
|
||||
|
||||
This handles the following paths:
|
||||
/api/states/<entity_id>
|
||||
"""
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
try:
|
||||
new_state = data['state']
|
||||
except KeyError:
|
||||
handler.write_json_message("state not specified", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
attributes = data['attributes'] if 'attributes' in data else None
|
||||
|
||||
is_new_state = handler.server.hass.states.get(entity_id) is None
|
||||
|
||||
# Write state
|
||||
handler.server.hass.states.set(entity_id, new_state, attributes)
|
||||
|
||||
state = handler.server.hass.states.get(entity_id)
|
||||
|
||||
status_code = HTTP_CREATED if is_new_state else HTTP_OK
|
||||
|
||||
handler.write_json(
|
||||
state.as_dict(),
|
||||
status_code=status_code,
|
||||
location=URL_API_STATES_ENTITY.format(entity_id))
|
||||
|
||||
|
||||
def _handle_get_api_events(handler, path_match, data):
|
||||
""" Handles getting overview of event listeners. """
|
||||
handler.write_json(_events_json(handler.server.hass))
|
||||
|
||||
|
||||
def _handle_api_post_events_event(handler, path_match, event_data):
|
||||
""" Handles firing of an event.
|
||||
|
||||
This handles the following paths:
|
||||
/api/events/<event_type>
|
||||
|
||||
Events from /api are threated as remote events.
|
||||
"""
|
||||
event_type = path_match.group('event_type')
|
||||
|
||||
if event_data is not None and not isinstance(event_data, dict):
|
||||
handler.write_json_message(
|
||||
"event_data should be an object", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
event_origin = ha.EventOrigin.remote
|
||||
|
||||
# Special case handling for event STATE_CHANGED
|
||||
# We will try to convert state dicts back to State objects
|
||||
if event_type == ha.EVENT_STATE_CHANGED and event_data:
|
||||
for key in ('old_state', 'new_state'):
|
||||
state = ha.State.from_dict(event_data.get(key))
|
||||
|
||||
if state:
|
||||
event_data[key] = state
|
||||
|
||||
handler.server.hass.bus.fire(event_type, event_data, event_origin)
|
||||
|
||||
handler.write_json_message("Event {} fired.".format(event_type))
|
||||
|
||||
|
||||
def _handle_get_api_services(handler, path_match, data):
|
||||
""" Handles getting overview of services. """
|
||||
handler.write_json(_services_json(handler.server.hass))
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_post_api_services_domain_service(handler, path_match, data):
|
||||
""" Handles calling a service.
|
||||
|
||||
This handles the following paths:
|
||||
/api/services/<domain>/<service>
|
||||
"""
|
||||
domain = path_match.group('domain')
|
||||
service = path_match.group('service')
|
||||
|
||||
with TrackStates(handler.server.hass) as changed_states:
|
||||
handler.server.hass.services.call(domain, service, data, True)
|
||||
|
||||
handler.write_json(changed_states)
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_post_api_event_forward(handler, path_match, data):
|
||||
""" Handles adding an event forwarding target. """
|
||||
|
||||
try:
|
||||
host = data['host']
|
||||
api_password = data['api_password']
|
||||
except KeyError:
|
||||
handler.write_json_message(
|
||||
"No host or api_password received.", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
try:
|
||||
port = int(data['port']) if 'port' in data else None
|
||||
except ValueError:
|
||||
handler.write_json_message(
|
||||
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
api = rem.API(host, api_password, port)
|
||||
|
||||
if not api.validate_api():
|
||||
handler.write_json_message(
|
||||
"Unable to validate API", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
if handler.server.event_forwarder is None:
|
||||
handler.server.event_forwarder = \
|
||||
rem.EventForwarder(handler.server.hass)
|
||||
|
||||
handler.server.event_forwarder.connect(api)
|
||||
|
||||
handler.write_json_message("Event forwarding setup.")
|
||||
|
||||
|
||||
def _handle_delete_api_event_forward(handler, path_match, data):
|
||||
""" Handles deleting an event forwarding target. """
|
||||
|
||||
try:
|
||||
host = data['host']
|
||||
except KeyError:
|
||||
handler.write_json_message("No host received.", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
try:
|
||||
port = int(data['port']) if 'port' in data else None
|
||||
except ValueError:
|
||||
handler.write_json_message(
|
||||
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
if handler.server.event_forwarder is not None:
|
||||
api = rem.API(host, None, port)
|
||||
|
||||
handler.server.event_forwarder.disconnect(api)
|
||||
|
||||
handler.write_json_message("Event forwarding cancelled.")
|
||||
|
||||
|
||||
def _handle_get_api_components(handler, path_match, data):
|
||||
""" Returns all the loaded components. """
|
||||
|
||||
handler.write_json(handler.server.hass.config.components)
|
||||
|
||||
|
||||
def _services_json(hass):
|
||||
""" Generate services data to JSONify. """
|
||||
return [{"domain": key, "services": value}
|
||||
for key, value in hass.services.services.items()]
|
||||
|
||||
|
||||
def _events_json(hass):
|
||||
""" Generate event data to JSONify. """
|
||||
return [{"event": key, "listener_count": value}
|
||||
for key, value in hass.bus.listeners.items()]
|
||||
143
homeassistant/components/arduino.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
components.arduino
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
Arduino component that connects to a directly attached Arduino board which
|
||||
runs with the Firmata firmware.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Arduino board you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
arduino:
|
||||
port: /dev/ttyACM0
|
||||
|
||||
Variables:
|
||||
|
||||
port
|
||||
*Required
|
||||
The port where is your board connected to your Home Assistant system.
|
||||
If you are using an original Arduino the port will be named ttyACM*. The exact
|
||||
number can be determined with 'ls /dev/ttyACM*' or check your 'dmesg'/
|
||||
'journalctl -f' output. Keep in mind that Arduino clones are often using a
|
||||
different name for the port (e.g. '/dev/ttyUSB*').
|
||||
|
||||
A word of caution: The Arduino is not storing states. This means that with
|
||||
every initialization the pins are set to off/low.
|
||||
"""
|
||||
import logging
|
||||
|
||||
try:
|
||||
from PyMata.pymata import PyMata
|
||||
except ImportError:
|
||||
PyMata = None
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.const import (EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
|
||||
DOMAIN = "arduino"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['PyMata==2.07a']
|
||||
BOARD = None
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup the Arduino component. """
|
||||
|
||||
global PyMata # pylint: disable=invalid-name
|
||||
if PyMata is None:
|
||||
from PyMata.pymata import PyMata as PyMata_
|
||||
PyMata = PyMata_
|
||||
|
||||
import serial
|
||||
|
||||
if not validate_config(config,
|
||||
{DOMAIN: ['port']},
|
||||
_LOGGER):
|
||||
return False
|
||||
|
||||
global BOARD
|
||||
try:
|
||||
BOARD = ArduinoBoard(config[DOMAIN]['port'])
|
||||
except (serial.serialutil.SerialException, FileNotFoundError):
|
||||
_LOGGER.exception("Your port is not accessible.")
|
||||
return False
|
||||
|
||||
if BOARD.get_firmata()[1] <= 2:
|
||||
_LOGGER.error("The StandardFirmata sketch should be 2.2 or newer.")
|
||||
return False
|
||||
|
||||
def stop_arduino(event):
|
||||
""" Stop the Arduino service. """
|
||||
BOARD.disconnect()
|
||||
|
||||
def start_arduino(event):
|
||||
""" Start the Arduino service. """
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_arduino)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_arduino)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ArduinoBoard(object):
|
||||
""" Represents an Arduino board. """
|
||||
|
||||
def __init__(self, port):
|
||||
self._port = port
|
||||
self._board = PyMata(self._port, verbose=False)
|
||||
|
||||
def set_mode(self, pin, direction, mode):
|
||||
""" Sets the mode and the direction of a given pin. """
|
||||
if mode == 'analog' and direction == 'in':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.INPUT,
|
||||
self._board.ANALOG)
|
||||
elif mode == 'analog' and direction == 'out':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.OUTPUT,
|
||||
self._board.ANALOG)
|
||||
elif mode == 'digital' and direction == 'in':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.OUTPUT,
|
||||
self._board.DIGITAL)
|
||||
elif mode == 'digital' and direction == 'out':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.OUTPUT,
|
||||
self._board.DIGITAL)
|
||||
elif mode == 'pwm':
|
||||
self._board.set_pin_mode(pin,
|
||||
self._board.OUTPUT,
|
||||
self._board.PWM)
|
||||
|
||||
def get_analog_inputs(self):
|
||||
""" Get the values from the pins. """
|
||||
self._board.capability_query()
|
||||
return self._board.get_analog_response_table()
|
||||
|
||||
def set_digital_out_high(self, pin):
|
||||
""" Sets a given digital pin to high. """
|
||||
self._board.digital_write(pin, 1)
|
||||
|
||||
def set_digital_out_low(self, pin):
|
||||
""" Sets a given digital pin to low. """
|
||||
self._board.digital_write(pin, 0)
|
||||
|
||||
def get_digital_in(self, pin):
|
||||
""" Gets the value from a given digital pin. """
|
||||
self._board.digital_read(pin)
|
||||
|
||||
def get_analog_in(self, pin):
|
||||
""" Gets the value from a given analog pin. """
|
||||
self._board.analog_read(pin)
|
||||
|
||||
def get_firmata(self):
|
||||
""" Return the version of the Firmata firmware. """
|
||||
return self._board.get_firmata_version()
|
||||
|
||||
def disconnect(self):
|
||||
""" Disconnects the board and closes the serial connection. """
|
||||
self._board.reset()
|
||||
self._board.close()
|
||||
74
homeassistant/components/automation/__init__.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
homeassistant.components.automation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Allows to setup simple automation rules via the config file.
|
||||
"""
|
||||
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
|
||||
|
||||
DOMAIN = "automation"
|
||||
|
||||
DEPENDENCIES = ["group"]
|
||||
|
||||
CONF_ALIAS = "alias"
|
||||
CONF_SERVICE = "execute_service"
|
||||
CONF_SERVICE_ENTITY_ID = "service_entity_id"
|
||||
CONF_SERVICE_DATA = "service_data"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Sets up automation. """
|
||||
success = False
|
||||
|
||||
for p_type, p_config in config_per_platform(config, DOMAIN, _LOGGER):
|
||||
platform = prepare_setup_platform(hass, config, DOMAIN, p_type)
|
||||
|
||||
if platform is None:
|
||||
_LOGGER.error("Unknown automation platform specified: %s", p_type)
|
||||
continue
|
||||
|
||||
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
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Error setting up rule %s", p_config.get(CONF_ALIAS, ""))
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def _get_action(hass, config):
|
||||
""" Return an action based on a config. """
|
||||
|
||||
def action():
|
||||
""" Action to be executed. """
|
||||
_LOGGER.info("Executing rule %s", config.get(CONF_ALIAS, ""))
|
||||
|
||||
if CONF_SERVICE in config:
|
||||
domain, service = split_entity_id(config[CONF_SERVICE])
|
||||
|
||||
service_data = config.get(CONF_SERVICE_DATA, {})
|
||||
|
||||
if not isinstance(service_data, dict):
|
||||
_LOGGER.error("%s should be a dictionary", CONF_SERVICE_DATA)
|
||||
service_data = {}
|
||||
|
||||
if CONF_SERVICE_ENTITY_ID in config:
|
||||
try:
|
||||
service_data[ATTR_ENTITY_ID] = \
|
||||
config[CONF_SERVICE_ENTITY_ID].split(",")
|
||||
except AttributeError:
|
||||
service_data[ATTR_ENTITY_ID] = \
|
||||
config[CONF_SERVICE_ENTITY_ID]
|
||||
|
||||
hass.services.call(domain, service, service_data)
|
||||
|
||||
return action
|
||||
31
homeassistant/components/automation/event.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
homeassistant.components.automation.event
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers event listening automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
CONF_EVENT_TYPE = "event_type"
|
||||
CONF_EVENT_DATA = "event_data"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register(hass, config, action):
|
||||
""" Listen for events based on config. """
|
||||
event_type = config.get(CONF_EVENT_TYPE)
|
||||
|
||||
if event_type is None:
|
||||
_LOGGER.error("Missing configuration key %s", CONF_EVENT_TYPE)
|
||||
return False
|
||||
|
||||
event_data = config.get(CONF_EVENT_DATA, {})
|
||||
|
||||
def handle_event(event):
|
||||
""" Listens for events and calls the action when data matches. """
|
||||
if event_data == event.data:
|
||||
action()
|
||||
|
||||
hass.bus.listen(event_type, handle_event)
|
||||
return True
|
||||
34
homeassistant/components/automation/mqtt.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
homeassistant.components.automation.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers MQTT listening automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
CONF_TOPIC = 'mqtt_topic'
|
||||
CONF_PAYLOAD = 'mqtt_payload'
|
||||
|
||||
|
||||
def register(hass, config, action):
|
||||
""" Listen for state changes based on `config`. """
|
||||
topic = config.get(CONF_TOPIC)
|
||||
payload = config.get(CONF_PAYLOAD)
|
||||
|
||||
if topic is None:
|
||||
logging.getLogger(__name__).error(
|
||||
"Missing configuration key %s", CONF_TOPIC)
|
||||
return False
|
||||
|
||||
def mqtt_automation_listener(msg_topic, msg_payload, qos):
|
||||
""" Listens for MQTT messages. """
|
||||
if payload is None or payload == msg_payload:
|
||||
action()
|
||||
|
||||
mqtt.subscribe(hass, topic, mqtt_automation_listener)
|
||||
|
||||
return True
|
||||
37
homeassistant/components/automation/state.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
homeassistant.components.automation.state
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers state listening automation rules.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.const import MATCH_ALL
|
||||
|
||||
|
||||
CONF_ENTITY_ID = "state_entity_id"
|
||||
CONF_FROM = "state_from"
|
||||
CONF_TO = "state_to"
|
||||
|
||||
|
||||
def register(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)
|
||||
return False
|
||||
|
||||
from_state = config.get(CONF_FROM, MATCH_ALL)
|
||||
to_state = config.get(CONF_TO, MATCH_ALL)
|
||||
|
||||
def state_automation_listener(entity, from_s, to_s):
|
||||
""" Listens for state changes and calls action. """
|
||||
action()
|
||||
|
||||
track_state_change(
|
||||
hass, entity_id, state_automation_listener, from_state, to_state)
|
||||
|
||||
return True
|
||||
28
homeassistant/components/automation/time.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
homeassistant.components.automation.time
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Offers time listening automation rules.
|
||||
"""
|
||||
from homeassistant.util import convert
|
||||
from homeassistant.helpers.event import track_time_change
|
||||
|
||||
CONF_HOURS = "time_hours"
|
||||
CONF_MINUTES = "time_minutes"
|
||||
CONF_SECONDS = "time_seconds"
|
||||
|
||||
|
||||
def register(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)
|
||||
|
||||
def time_automation_listener(now):
|
||||
""" Listens for time changes and calls action. """
|
||||
action()
|
||||
|
||||
track_time_change(hass, time_automation_listener,
|
||||
hour=hours, minute=minutes, second=seconds)
|
||||
|
||||
return True
|
||||
@@ -6,20 +6,21 @@ Provides functionality to launch a webbrowser on the host machine.
|
||||
"""
|
||||
|
||||
DOMAIN = "browser"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_BROWSE_URL = "browse_url"
|
||||
|
||||
|
||||
def setup(bus):
|
||||
def setup(hass, config):
|
||||
""" Listen for browse_url events and open
|
||||
the url in the default webbrowser. """
|
||||
|
||||
import webbrowser
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_BROWSE_URL,
|
||||
lambda service:
|
||||
webbrowser.open(
|
||||
service.data.get('url',
|
||||
'https://www.google.com')))
|
||||
hass.services.register(DOMAIN, SERVICE_BROWSE_URL,
|
||||
lambda service:
|
||||
webbrowser.open(
|
||||
service.data.get(
|
||||
'url', 'https://www.google.com')))
|
||||
|
||||
return True
|
||||
|
||||
229
homeassistant/components/camera/__init__.py
Normal file
@@ -0,0 +1,229 @@
|
||||
# pylint: disable=too-many-lines
|
||||
"""
|
||||
homeassistant.components.camera
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Component to interface with various cameras.
|
||||
|
||||
The following features are supported:
|
||||
- Returning recorded camera images and streams
|
||||
- Proxying image requests via HA for external access
|
||||
- Converting a still image url into a live video stream
|
||||
|
||||
Upcoming features
|
||||
- Recording
|
||||
- Snapshot
|
||||
- Motion Detection Recording(for supported cameras)
|
||||
- Automatic Configuration(for supported cameras)
|
||||
- Creation of child entities for supported functions
|
||||
- Collating motion event images passed via FTP into time based events
|
||||
- A service for calling camera functions
|
||||
- Camera movement(panning)
|
||||
- Zoom
|
||||
- Light/Nightvision toggling
|
||||
- Support for more devices
|
||||
- Expanded documentation
|
||||
"""
|
||||
import requests
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_PICTURE,
|
||||
HTTP_NOT_FOUND,
|
||||
ATTR_ENTITY_ID,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
|
||||
DOMAIN = 'camera'
|
||||
DEPENDENCIES = ['http']
|
||||
GROUP_NAME_ALL_CAMERAS = 'all_cameras'
|
||||
SCAN_INTERVAL = 30
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
SWITCH_ACTION_RECORD = 'record'
|
||||
SWITCH_ACTION_SNAPSHOT = 'snapshot'
|
||||
|
||||
SERVICE_CAMERA = 'camera_service'
|
||||
|
||||
STATE_RECORDING = 'recording'
|
||||
|
||||
DEFAULT_RECORDING_SECONDS = 30
|
||||
|
||||
# Maps discovered services to their platforms
|
||||
DISCOVERY_PLATFORMS = {}
|
||||
|
||||
FILE_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S-%f'
|
||||
DIR_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S'
|
||||
|
||||
REC_DIR_PREFIX = 'recording-'
|
||||
REC_IMG_PREFIX = 'recording_image-'
|
||||
|
||||
STATE_STREAMING = 'streaming'
|
||||
STATE_IDLE = 'idle'
|
||||
|
||||
CAMERA_PROXY_URL = '/api/camera_proxy_stream/{0}'
|
||||
CAMERA_STILL_URL = '/api/camera_proxy/{0}'
|
||||
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?time={1}'
|
||||
|
||||
MULTIPART_BOUNDARY = '--jpegboundary'
|
||||
MJPEG_START_HEADER = 'Content-type: {0}\r\n\r\n'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def setup(hass, config):
|
||||
""" Track states and offer events for sensors. """
|
||||
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL,
|
||||
DISCOVERY_PLATFORMS)
|
||||
|
||||
component.setup(config)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# CAMERA COMPONENT ENDPOINTS
|
||||
# -------------------------------------------------------------------------
|
||||
# The following defines the endpoints for serving images from the camera
|
||||
# via the HA http server. This is means that you can access images from
|
||||
# your camera outside of your LAN without the need for port forwards etc.
|
||||
|
||||
# Because the authentication header can't be added in image requests these
|
||||
# endpoints are secured with session based security.
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _proxy_camera_image(handler, path_match, data):
|
||||
""" Proxies the camera image via the HA server. """
|
||||
entity_id = path_match.group(ATTR_ENTITY_ID)
|
||||
|
||||
camera = None
|
||||
if entity_id in component.entities.keys():
|
||||
camera = component.entities[entity_id]
|
||||
|
||||
if camera:
|
||||
response = camera.camera_image()
|
||||
handler.wfile.write(response)
|
||||
else:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(r'/api/camera_proxy/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_proxy_camera_image)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _proxy_camera_mjpeg_stream(handler, path_match, data):
|
||||
""" Proxies the camera image as an mjpeg stream via the HA server.
|
||||
This function takes still images from the IP camera and turns them
|
||||
into an MJPEG stream. This means that HA can return a live video
|
||||
stream even with only a still image URL available.
|
||||
"""
|
||||
entity_id = path_match.group(ATTR_ENTITY_ID)
|
||||
|
||||
camera = None
|
||||
if entity_id in component.entities.keys():
|
||||
camera = component.entities[entity_id]
|
||||
|
||||
if not camera:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
handler.end_headers()
|
||||
return
|
||||
|
||||
try:
|
||||
camera.is_streaming = True
|
||||
camera.update_ha_state()
|
||||
|
||||
handler.request.sendall(bytes('HTTP/1.1 200 OK\r\n', 'utf-8'))
|
||||
handler.request.sendall(bytes(
|
||||
'Content-type: multipart/x-mixed-replace; \
|
||||
boundary=--jpgboundary\r\n\r\n', 'utf-8'))
|
||||
handler.request.sendall(bytes('--jpgboundary\r\n', 'utf-8'))
|
||||
|
||||
# MJPEG_START_HEADER.format()
|
||||
|
||||
while True:
|
||||
|
||||
img_bytes = camera.camera_image()
|
||||
|
||||
headers_str = '\r\n'.join((
|
||||
'Content-length: {}'.format(len(img_bytes)),
|
||||
'Content-type: image/jpeg',
|
||||
)) + '\r\n\r\n'
|
||||
|
||||
handler.request.sendall(
|
||||
bytes(headers_str, 'utf-8') +
|
||||
img_bytes +
|
||||
bytes('\r\n', 'utf-8'))
|
||||
|
||||
handler.request.sendall(
|
||||
bytes('--jpgboundary\r\n', 'utf-8'))
|
||||
|
||||
except (requests.RequestException, IOError):
|
||||
camera.is_streaming = False
|
||||
camera.update_ha_state()
|
||||
|
||||
camera.is_streaming = False
|
||||
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(
|
||||
r'/api/camera_proxy_stream/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_proxy_camera_mjpeg_stream)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Camera(Entity):
|
||||
""" The base class for camera components """
|
||||
|
||||
def __init__(self):
|
||||
self.is_streaming = False
|
||||
|
||||
@property
|
||||
# pylint: disable=no-self-use
|
||||
def is_recording(self):
|
||||
""" Returns true if the device is recording """
|
||||
return False
|
||||
|
||||
@property
|
||||
# pylint: disable=no-self-use
|
||||
def brand(self):
|
||||
""" Should return a string of the camera brand """
|
||||
return None
|
||||
|
||||
@property
|
||||
# pylint: disable=no-self-use
|
||||
def model(self):
|
||||
""" Returns string of camera model """
|
||||
return None
|
||||
|
||||
def camera_image(self):
|
||||
""" Return bytes of camera image """
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the entity. """
|
||||
if self.is_recording:
|
||||
return STATE_RECORDING
|
||||
elif self.is_streaming:
|
||||
return STATE_STREAMING
|
||||
else:
|
||||
return STATE_IDLE
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Returns optional state attributes. """
|
||||
attr = {
|
||||
ATTR_ENTITY_PICTURE: ENTITY_IMAGE_URL.format(
|
||||
self.entity_id, time.time()),
|
||||
}
|
||||
|
||||
if self.model:
|
||||
attr['model_name'] = self.model
|
||||
|
||||
if self.brand:
|
||||
attr['brand'] = self.brand
|
||||
|
||||
return attr
|
||||
91
homeassistant/components/camera/generic.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
homeassistant.components.camera.generic
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for IP Cameras.
|
||||
|
||||
This component provides basic support for IP cameras. For the basic support to
|
||||
work you camera must support accessing a JPEG snapshot via a URL and you will
|
||||
need to specify the "still_image_url" parameter which should be the location of
|
||||
the JPEG image.
|
||||
|
||||
As part of the basic support the following features will be provided:
|
||||
- MJPEG video streaming
|
||||
- Saving a snapshot
|
||||
- Recording(JPEG frame capture)
|
||||
|
||||
To use this component, add the following to your configuration.yaml file.
|
||||
|
||||
camera:
|
||||
platform: generic
|
||||
name: Door Camera
|
||||
username: YOUR_USERNAME
|
||||
password: YOUR_PASSWORD
|
||||
still_image_url: http://YOUR_CAMERA_IP_AND_PORT/image.jpg
|
||||
|
||||
Variables:
|
||||
|
||||
still_image_url
|
||||
*Required
|
||||
The URL your camera serves the image on, eg. http://192.168.1.21:2112/
|
||||
|
||||
name
|
||||
*Optional
|
||||
This parameter allows you to override the name of your camera in Home
|
||||
Assistant.
|
||||
|
||||
username
|
||||
*Optional
|
||||
The username for accessing your camera.
|
||||
|
||||
password
|
||||
*Optional
|
||||
The password for accessing your camera.
|
||||
"""
|
||||
import logging
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.camera import DOMAIN
|
||||
from homeassistant.components.camera import Camera
|
||||
import requests
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Adds a generic IP Camera. """
|
||||
if not validate_config({DOMAIN: config}, {DOMAIN: ['still_image_url']},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
add_devices_callback([GenericCamera(config)])
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class GenericCamera(Camera):
|
||||
"""
|
||||
A generic implementation of an IP camera that is reachable over a URL.
|
||||
"""
|
||||
|
||||
def __init__(self, device_info):
|
||||
super().__init__()
|
||||
self._name = device_info.get('name', 'Generic Camera')
|
||||
self._username = device_info.get('username')
|
||||
self._password = device_info.get('password')
|
||||
self._still_image_url = device_info['still_image_url']
|
||||
|
||||
def camera_image(self):
|
||||
""" Return a still image reponse from the camera. """
|
||||
if self._username and self._password:
|
||||
response = requests.get(
|
||||
self._still_image_url,
|
||||
auth=HTTPBasicAuth(self._username, self._password))
|
||||
else:
|
||||
response = requests.get(self._still_image_url)
|
||||
|
||||
return response.content
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Return the name of this device. """
|
||||
return self._name
|
||||
@@ -1,275 +0,0 @@
|
||||
"""
|
||||
homeassistant.components.chromecast
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to interact with Chromecasts.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.util as util
|
||||
import homeassistant.components as components
|
||||
|
||||
DOMAIN = 'chromecast'
|
||||
|
||||
SERVICE_YOUTUBE_VIDEO = 'play_youtube_video'
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
STATE_NO_APP = 'no_app'
|
||||
|
||||
ATTR_HOST = 'host'
|
||||
ATTR_STATE = 'state'
|
||||
ATTR_OPTIONS = 'options'
|
||||
ATTR_MEDIA_STATE = 'media_state'
|
||||
ATTR_MEDIA_CONTENT_ID = 'media_content_id'
|
||||
ATTR_MEDIA_TITLE = 'media_title'
|
||||
ATTR_MEDIA_ARTIST = 'media_artist'
|
||||
ATTR_MEDIA_ALBUM = 'media_album'
|
||||
ATTR_MEDIA_IMAGE_URL = 'media_image_url'
|
||||
ATTR_MEDIA_VOLUME = 'media_volume'
|
||||
ATTR_MEDIA_DURATION = 'media_duration'
|
||||
|
||||
MEDIA_STATE_UNKNOWN = 'unknown'
|
||||
MEDIA_STATE_PLAYING = 'playing'
|
||||
MEDIA_STATE_STOPPED = 'stopped'
|
||||
|
||||
|
||||
def is_on(statemachine, entity_id=None):
|
||||
""" Returns true if specified ChromeCast entity_id is on.
|
||||
Will check all chromecasts if no entity_id specified. """
|
||||
|
||||
entity_ids = [entity_id] if entity_id \
|
||||
else util.filter_entity_ids(statemachine.entity_ids, DOMAIN)
|
||||
|
||||
return any(not statemachine.is_state(entity_id, STATE_NO_APP)
|
||||
for entity_id in entity_ids)
|
||||
|
||||
|
||||
def turn_off(bus, entity_id=None):
|
||||
""" Will turn off specified Chromecast or all. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_TURN_OFF, data)
|
||||
|
||||
|
||||
def volume_up(bus, entity_id=None):
|
||||
""" Send the chromecast the command for volume up. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_UP, data)
|
||||
|
||||
|
||||
def volume_down(bus, entity_id=None):
|
||||
""" Send the chromecast the command for volume down. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_DOWN, data)
|
||||
|
||||
|
||||
def media_play_pause(bus, entity_id=None):
|
||||
""" Send the chromecast the command for play/pause. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_PLAY_PAUSE, data)
|
||||
|
||||
|
||||
def media_next_track(bus, entity_id=None):
|
||||
""" Send the chromecast the command for next track. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_NEXT_TRACK, data)
|
||||
|
||||
|
||||
def media_prev_track(bus, entity_id=None):
|
||||
""" Send the chromecast the command for prev track. """
|
||||
data = {components.ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_PREV_TRACK, data)
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals, too-many-branches
|
||||
def setup(bus, statemachine):
|
||||
""" Listen for chromecast events. """
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import pychromecast
|
||||
except ImportError:
|
||||
logger.exception(("Failed to import pychromecast. "
|
||||
"Did you maybe not install the 'pychromecast' "
|
||||
"dependency?"))
|
||||
|
||||
return False
|
||||
|
||||
logger.info("Scanning for Chromecasts")
|
||||
hosts = pychromecast.discover_chromecasts()
|
||||
|
||||
casts = {}
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
cast = pychromecast.PyChromecast(host)
|
||||
|
||||
entity_id = util.ensure_unique_string(
|
||||
ENTITY_ID_FORMAT.format(
|
||||
util.slugify(cast.device.friendly_name)),
|
||||
casts.keys())
|
||||
|
||||
casts[entity_id] = cast
|
||||
|
||||
except pychromecast.ConnectionError:
|
||||
pass
|
||||
|
||||
if not casts:
|
||||
logger.error("Could not find Chromecasts")
|
||||
return False
|
||||
|
||||
def update_chromecast_state(entity_id, chromecast):
|
||||
""" Retrieve state of Chromecast and update statemachine. """
|
||||
chromecast.refresh()
|
||||
|
||||
status = chromecast.app
|
||||
|
||||
state_attr = {ATTR_HOST: chromecast.host,
|
||||
components.ATTR_FRIENDLY_NAME:
|
||||
chromecast.device.friendly_name}
|
||||
|
||||
if status and status.app_id != pychromecast.APP_ID['HOME']:
|
||||
state = status.app_id
|
||||
|
||||
ramp = chromecast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp and ramp.state != pychromecast.RAMP_STATE_UNKNOWN:
|
||||
|
||||
if ramp.state == pychromecast.RAMP_STATE_PLAYING:
|
||||
state_attr[ATTR_MEDIA_STATE] = MEDIA_STATE_PLAYING
|
||||
else:
|
||||
state_attr[ATTR_MEDIA_STATE] = MEDIA_STATE_STOPPED
|
||||
|
||||
if ramp.content_id:
|
||||
state_attr[ATTR_MEDIA_CONTENT_ID] = ramp.content_id
|
||||
|
||||
if ramp.title:
|
||||
state_attr[ATTR_MEDIA_TITLE] = ramp.title
|
||||
|
||||
if ramp.artist:
|
||||
state_attr[ATTR_MEDIA_ARTIST] = ramp.artist
|
||||
|
||||
if ramp.album:
|
||||
state_attr[ATTR_MEDIA_ALBUM] = ramp.album
|
||||
|
||||
if ramp.image_url:
|
||||
state_attr[ATTR_MEDIA_IMAGE_URL] = ramp.image_url
|
||||
|
||||
if ramp.duration:
|
||||
state_attr[ATTR_MEDIA_DURATION] = ramp.duration
|
||||
|
||||
state_attr[ATTR_MEDIA_VOLUME] = ramp.volume
|
||||
else:
|
||||
state = STATE_NO_APP
|
||||
|
||||
statemachine.set_state(entity_id, state, state_attr)
|
||||
|
||||
def update_chromecast_states(time): # pylint: disable=unused-argument
|
||||
""" Updates all chromecast states. """
|
||||
logger.info("Updating Chromecast status")
|
||||
|
||||
for entity_id, cast in casts.items():
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
def _service_to_entities(service):
|
||||
""" Helper method to get entities from service. """
|
||||
entity_ids = components.extract_entity_ids(statemachine, service)
|
||||
|
||||
if entity_ids:
|
||||
for entity_id in entity_ids:
|
||||
cast = casts.get(entity_id)
|
||||
|
||||
if cast:
|
||||
yield entity_id, cast
|
||||
|
||||
else:
|
||||
for item in casts.items():
|
||||
yield item
|
||||
|
||||
def turn_off_service(service):
|
||||
""" Service to exit any running app on the specified ChromeCast and
|
||||
shows idle screen. Will quit all ChromeCasts if nothing specified.
|
||||
"""
|
||||
for entity_id, cast in _service_to_entities(service):
|
||||
cast.quit_app()
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
def volume_up_service(service):
|
||||
""" Service to send the chromecast the command for volume up. """
|
||||
for _, cast in _service_to_entities(service):
|
||||
ramp = cast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp:
|
||||
ramp.volume_up()
|
||||
|
||||
def volume_down_service(service):
|
||||
""" Service to send the chromecast the command for volume down. """
|
||||
for _, cast in _service_to_entities(service):
|
||||
ramp = cast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp:
|
||||
ramp.volume_down()
|
||||
|
||||
def media_play_pause_service(service):
|
||||
""" Service to send the chromecast the command for play/pause. """
|
||||
for _, cast in _service_to_entities(service):
|
||||
ramp = cast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp:
|
||||
ramp.playpause()
|
||||
|
||||
def media_next_track_service(service):
|
||||
""" Service to send the chromecast the command for next track. """
|
||||
for entity_id, cast in _service_to_entities(service):
|
||||
ramp = cast.get_protocol(pychromecast.PROTOCOL_RAMP)
|
||||
|
||||
if ramp:
|
||||
ramp.next()
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
def play_youtube_video_service(service, video_id):
|
||||
""" Plays specified video_id on the Chromecast's YouTube channel. """
|
||||
if video_id: # if service.data.get('video') returned None
|
||||
for entity_id, cast in _service_to_entities(service):
|
||||
pychromecast.play_youtube_video(video_id, cast.host)
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
ha.track_time_change(bus, update_chromecast_states)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_TURN_OFF,
|
||||
turn_off_service)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_UP,
|
||||
volume_up_service)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_DOWN,
|
||||
volume_down_service)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_PLAY_PAUSE,
|
||||
media_play_pause_service)
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_NEXT_TRACK,
|
||||
media_next_track_service)
|
||||
|
||||
bus.register_service(DOMAIN, "start_fireplace",
|
||||
lambda service:
|
||||
play_youtube_video_service(service, "eyU3bRy2x44"))
|
||||
|
||||
bus.register_service(DOMAIN, "start_epic_sax",
|
||||
lambda service:
|
||||
play_youtube_video_service(service, "kxopViU98Xo"))
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_YOUTUBE_VIDEO,
|
||||
lambda service:
|
||||
play_youtube_video_service(service,
|
||||
service.data.get('video')))
|
||||
|
||||
update_chromecast_states(None)
|
||||
|
||||
return True
|
||||
190
homeassistant/components/configurator.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
homeassistant.components.configurator
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A component to allow pieces of code to request configuration from the user.
|
||||
|
||||
Initiate a request by calling the `request_config` method with a callback.
|
||||
This will return a request id that has to be used for future calls.
|
||||
A callback has to be provided to `request_config` which will be called when
|
||||
the user has submitted configuration information.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers import generate_entity_id
|
||||
from homeassistant.const import EVENT_TIME_CHANGED
|
||||
|
||||
DOMAIN = "configurator"
|
||||
DEPENDENCIES = []
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
SERVICE_CONFIGURE = "configure"
|
||||
|
||||
STATE_CONFIGURE = "configure"
|
||||
STATE_CONFIGURED = "configured"
|
||||
|
||||
ATTR_CONFIGURE_ID = "configure_id"
|
||||
ATTR_DESCRIPTION = "description"
|
||||
ATTR_DESCRIPTION_IMAGE = "description_image"
|
||||
ATTR_SUBMIT_CAPTION = "submit_caption"
|
||||
ATTR_FIELDS = "fields"
|
||||
ATTR_ERRORS = "errors"
|
||||
|
||||
_REQUESTS = {}
|
||||
_INSTANCES = {}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def request_config(
|
||||
hass, name, callback, description=None, description_image=None,
|
||||
submit_caption=None, fields=None):
|
||||
""" Create a new request for config.
|
||||
Will return an ID to be used for sequent calls. """
|
||||
|
||||
instance = _get_instance(hass)
|
||||
|
||||
request_id = instance.request_config(
|
||||
name, callback,
|
||||
description, description_image, submit_caption, fields)
|
||||
|
||||
_REQUESTS[request_id] = instance
|
||||
|
||||
return request_id
|
||||
|
||||
|
||||
def notify_errors(request_id, error):
|
||||
""" Add errors to a config request. """
|
||||
try:
|
||||
_REQUESTS[request_id].notify_errors(request_id, error)
|
||||
except KeyError:
|
||||
# If request_id does not exist
|
||||
pass
|
||||
|
||||
|
||||
def request_done(request_id):
|
||||
""" Mark a config request as done. """
|
||||
try:
|
||||
_REQUESTS.pop(request_id).request_done(request_id)
|
||||
except KeyError:
|
||||
# If request_id does not exist
|
||||
pass
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Set up Configurator. """
|
||||
return True
|
||||
|
||||
|
||||
def _get_instance(hass):
|
||||
""" Get an instance per hass object. """
|
||||
try:
|
||||
return _INSTANCES[hass]
|
||||
except KeyError:
|
||||
_INSTANCES[hass] = Configurator(hass)
|
||||
|
||||
if DOMAIN not in hass.config.components:
|
||||
hass.config.components.append(DOMAIN)
|
||||
|
||||
return _INSTANCES[hass]
|
||||
|
||||
|
||||
class Configurator(object):
|
||||
"""
|
||||
Class to keep track of current configuration requests.
|
||||
"""
|
||||
|
||||
def __init__(self, hass):
|
||||
self.hass = hass
|
||||
self._cur_id = 0
|
||||
self._requests = {}
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_CONFIGURE, self.handle_service_call)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def request_config(
|
||||
self, name, callback,
|
||||
description, description_image, submit_caption, fields):
|
||||
""" Setup a request for configuration. """
|
||||
|
||||
entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass)
|
||||
|
||||
if fields is None:
|
||||
fields = []
|
||||
|
||||
request_id = self._generate_unique_id()
|
||||
|
||||
self._requests[request_id] = (entity_id, fields, callback)
|
||||
|
||||
data = {
|
||||
ATTR_CONFIGURE_ID: request_id,
|
||||
ATTR_FIELDS: fields,
|
||||
}
|
||||
|
||||
data.update({
|
||||
key: value for key, value in [
|
||||
(ATTR_DESCRIPTION, description),
|
||||
(ATTR_DESCRIPTION_IMAGE, description_image),
|
||||
(ATTR_SUBMIT_CAPTION, submit_caption),
|
||||
] if value is not None
|
||||
})
|
||||
|
||||
self.hass.states.set(entity_id, STATE_CONFIGURE, data)
|
||||
|
||||
return request_id
|
||||
|
||||
def notify_errors(self, request_id, error):
|
||||
""" Update the state with errors. """
|
||||
if not self._validate_request_id(request_id):
|
||||
return
|
||||
|
||||
entity_id = self._requests[request_id][0]
|
||||
|
||||
state = self.hass.states.get(entity_id)
|
||||
|
||||
new_data = state.attributes
|
||||
new_data[ATTR_ERRORS] = error
|
||||
|
||||
self.hass.states.set(entity_id, STATE_CONFIGURE, new_data)
|
||||
|
||||
def request_done(self, request_id):
|
||||
""" Remove the config request. """
|
||||
if not self._validate_request_id(request_id):
|
||||
return
|
||||
|
||||
entity_id = self._requests.pop(request_id)[0]
|
||||
|
||||
# If we remove the state right away, it will not be included with
|
||||
# the result fo the service call (current design limitation).
|
||||
# Instead, we will set it to configured to give as feedback but delete
|
||||
# it shortly after so that it is deleted when the client updates.
|
||||
self.hass.states.set(entity_id, STATE_CONFIGURED)
|
||||
|
||||
def deferred_remove(event):
|
||||
""" Remove the request state. """
|
||||
self.hass.states.remove(entity_id)
|
||||
|
||||
self.hass.bus.listen_once(EVENT_TIME_CHANGED, deferred_remove)
|
||||
|
||||
def handle_service_call(self, call):
|
||||
""" Handle a configure service call. """
|
||||
request_id = call.data.get(ATTR_CONFIGURE_ID)
|
||||
|
||||
if not self._validate_request_id(request_id):
|
||||
return
|
||||
|
||||
# pylint: disable=unused-variable
|
||||
entity_id, fields, callback = self._requests[request_id]
|
||||
|
||||
# field validation goes here?
|
||||
|
||||
callback(call.data.get(ATTR_FIELDS, {}))
|
||||
|
||||
def _generate_unique_id(self):
|
||||
""" Generates a unique configurator id. """
|
||||
self._cur_id += 1
|
||||
return "{}-{}".format(id(self), self._cur_id)
|
||||
|
||||
def _validate_request_id(self, request_id):
|
||||
""" Validate that the request belongs to this instance. """
|
||||
return request_id in self._requests
|
||||
70
homeassistant/components/conversation.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
homeassistant.components.conversation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to have conversations with Home Assistant.
|
||||
This is more a proof of concept.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF)
|
||||
|
||||
DOMAIN = "conversation"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_PROCESS = "process"
|
||||
|
||||
ATTR_TEXT = "text"
|
||||
|
||||
REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)')
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Registers the process service. """
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def process(service):
|
||||
""" Parses text into commands for Home Assistant. """
|
||||
if ATTR_TEXT not in service.data:
|
||||
logger.error("Received process service call without a text")
|
||||
return
|
||||
|
||||
text = service.data[ATTR_TEXT].lower()
|
||||
|
||||
match = REGEX_TURN_COMMAND.match(text)
|
||||
|
||||
if not match:
|
||||
logger.error("Unable to process: %s", text)
|
||||
return
|
||||
|
||||
name, command = match.groups()
|
||||
|
||||
entity_ids = [
|
||||
state.entity_id for state in hass.states.all()
|
||||
if state.name.lower() == name]
|
||||
|
||||
if not entity_ids:
|
||||
logger.error(
|
||||
"Could not find entity id %s from text %s", name, text)
|
||||
return
|
||||
|
||||
if command == 'on':
|
||||
hass.services.call(core.DOMAIN, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
}, blocking=True)
|
||||
|
||||
elif command == 'off':
|
||||
hass.services.call(core.DOMAIN, SERVICE_TURN_OFF, {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
}, blocking=True)
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
'Got unsupported command %s from text %s', command, text)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_PROCESS, process)
|
||||
|
||||
return True
|
||||
149
homeassistant/components/demo.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
homeassistant.components.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Sets up a demo environment that mimics interaction with devices.
|
||||
"""
|
||||
import time
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.bootstrap as bootstrap
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.const import (
|
||||
CONF_PLATFORM, ATTR_ENTITY_PICTURE, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME)
|
||||
|
||||
DOMAIN = "demo"
|
||||
|
||||
DEPENDENCIES = ['introduction', 'conversation']
|
||||
|
||||
COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
'switch', 'light', 'thermostat', 'sensor', 'media_player', 'notify']
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup a demo environment. """
|
||||
group = loader.get_component('group')
|
||||
configurator = loader.get_component('configurator')
|
||||
|
||||
config.setdefault(ha.DOMAIN, {})
|
||||
config.setdefault(DOMAIN, {})
|
||||
|
||||
if config[DOMAIN].get('hide_demo_state') != 1:
|
||||
hass.states.set('a.Demo_Mode', 'Enabled')
|
||||
|
||||
# Setup sun
|
||||
if not hass.config.latitude:
|
||||
hass.config.latitude = '32.87336'
|
||||
|
||||
if not hass.config.longitude:
|
||||
hass.config.longitude = '117.22743'
|
||||
|
||||
bootstrap.setup_component(hass, 'sun')
|
||||
|
||||
# Setup demo platforms
|
||||
for component in COMPONENTS_WITH_DEMO_PLATFORM:
|
||||
bootstrap.setup_component(
|
||||
hass, component, {component: {CONF_PLATFORM: 'demo'}})
|
||||
|
||||
# Setup room groups
|
||||
lights = sorted(hass.states.entity_ids('light'))
|
||||
switches = sorted(hass.states.entity_ids('switch'))
|
||||
media_players = sorted(hass.states.entity_ids('media_player'))
|
||||
group.setup_group(hass, 'living room', [lights[2], lights[1], switches[0],
|
||||
media_players[1]])
|
||||
group.setup_group(hass, 'bedroom', [lights[0], switches[1],
|
||||
media_players[0]])
|
||||
|
||||
# Setup IP Camera
|
||||
bootstrap.setup_component(
|
||||
hass, 'camera',
|
||||
{'camera': {
|
||||
'platform': 'generic',
|
||||
'name': 'IP Camera',
|
||||
'still_image_url': 'http://194.218.96.92/jpg/image.jpg',
|
||||
}})
|
||||
|
||||
# Setup scripts
|
||||
bootstrap.setup_component(
|
||||
hass, 'script',
|
||||
{'script': {
|
||||
'demo': {
|
||||
'alias': 'Toggle {}'.format(lights[0].split('.')[1]),
|
||||
'sequence': [{
|
||||
'execute_service': 'light.turn_off',
|
||||
'service_data': {ATTR_ENTITY_ID: lights[0]}
|
||||
}, {
|
||||
'delay': {'seconds': 5}
|
||||
}, {
|
||||
'execute_service': 'light.turn_on',
|
||||
'service_data': {ATTR_ENTITY_ID: lights[0]}
|
||||
}, {
|
||||
'delay': {'seconds': 5}
|
||||
}, {
|
||||
'execute_service': 'light.turn_off',
|
||||
'service_data': {ATTR_ENTITY_ID: lights[0]}
|
||||
}]
|
||||
}}})
|
||||
|
||||
# Setup scenes
|
||||
bootstrap.setup_component(
|
||||
hass, 'scene',
|
||||
{'scene': [
|
||||
{'name': 'Romantic lights',
|
||||
'entities': {
|
||||
lights[0]: True,
|
||||
lights[1]: {'state': 'on', 'xy_color': [0.33, 0.66],
|
||||
'brightness': 200},
|
||||
}},
|
||||
{'name': 'Switch on and off',
|
||||
'entities': {
|
||||
switches[0]: True,
|
||||
switches[1]: False,
|
||||
}},
|
||||
]})
|
||||
|
||||
# Setup fake device tracker
|
||||
hass.states.set("device_tracker.paulus", "home",
|
||||
{ATTR_ENTITY_PICTURE:
|
||||
"http://graph.facebook.com/297400035/picture",
|
||||
ATTR_FRIENDLY_NAME: 'Paulus'})
|
||||
hass.states.set("device_tracker.anne_therese", "not_home",
|
||||
{ATTR_FRIENDLY_NAME: 'Anne Therese'})
|
||||
|
||||
hass.states.set("group.all_devices", "home",
|
||||
{
|
||||
"auto": True,
|
||||
ATTR_ENTITY_ID: [
|
||||
"device_tracker.paulus",
|
||||
"device_tracker.anne_therese"
|
||||
]
|
||||
})
|
||||
|
||||
# Setup configurator
|
||||
configurator_ids = []
|
||||
|
||||
def hue_configuration_callback(data):
|
||||
""" Fake callback, mark config as done. """
|
||||
time.sleep(2)
|
||||
|
||||
# First time it is called, pretend it failed.
|
||||
if len(configurator_ids) == 1:
|
||||
configurator.notify_errors(
|
||||
configurator_ids[0],
|
||||
"Failed to register, please try again.")
|
||||
|
||||
configurator_ids.append(0)
|
||||
else:
|
||||
configurator.request_done(configurator_ids[0])
|
||||
|
||||
request_id = configurator.request_config(
|
||||
hass, "Philips Hue", hue_configuration_callback,
|
||||
description=("Press the button on the bridge to register Philips Hue "
|
||||
"with Home Assistant."),
|
||||
description_image="/static/images/config_philips_hue.jpg",
|
||||
submit_caption="I have pressed the button"
|
||||
)
|
||||
|
||||
configurator_ids.append(request_id)
|
||||
|
||||
return True
|
||||
@@ -6,30 +6,44 @@ Provides functionality to turn on lights based on
|
||||
the state of the sun and devices.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.util as util
|
||||
import homeassistant.components as components
|
||||
from homeassistant.helpers.event import track_point_in_time, track_state_change
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
|
||||
from . import light, sun, device_tracker, group
|
||||
|
||||
DOMAIN = "device_sun_light_trigger"
|
||||
DEPENDENCIES = ['light', 'device_tracker', 'group', 'sun']
|
||||
|
||||
LIGHT_TRANSITION_TIME = timedelta(minutes=15)
|
||||
|
||||
# Light profile to be used if none given
|
||||
LIGHT_PROFILE = 'relax'
|
||||
|
||||
CONF_LIGHT_PROFILE = 'light_profile'
|
||||
CONF_LIGHT_GROUP = 'light_group'
|
||||
CONF_DEVICE_GROUP = 'device_group'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def setup(bus, statemachine,
|
||||
light_group=light.GROUP_NAME_ALL_LIGHTS,
|
||||
light_profile=LIGHT_PROFILE):
|
||||
def setup(hass, config):
|
||||
""" Triggers to turn lights on or off based on device precense. """
|
||||
|
||||
disable_turn_off = 'disable_turn_off' in config[DOMAIN]
|
||||
|
||||
light_group = config[DOMAIN].get(CONF_LIGHT_GROUP,
|
||||
light.ENTITY_ID_ALL_LIGHTS)
|
||||
|
||||
light_profile = config[DOMAIN].get(CONF_LIGHT_PROFILE, LIGHT_PROFILE)
|
||||
|
||||
device_group = config[DOMAIN].get(CONF_DEVICE_GROUP,
|
||||
device_tracker.ENTITY_ID_ALL_DEVICES)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
device_entity_ids = util.filter_entity_ids(statemachine.entity_ids,
|
||||
device_tracker.DOMAIN)
|
||||
device_entity_ids = group.get_entity_ids(hass, device_group,
|
||||
device_tracker.DOMAIN)
|
||||
|
||||
if not device_entity_ids:
|
||||
logger.error("No devices found to track")
|
||||
@@ -37,8 +51,7 @@ def setup(bus, statemachine,
|
||||
return False
|
||||
|
||||
# Get the light IDs from the specified group
|
||||
light_ids = util.filter_entity_ids(
|
||||
group.get_entity_ids(statemachine, light_group), light.DOMAIN)
|
||||
light_ids = group.get_entity_ids(hass, light_group, light.DOMAIN)
|
||||
|
||||
if not light_ids:
|
||||
logger.error("No lights found to turn on ")
|
||||
@@ -48,14 +61,13 @@ def setup(bus, statemachine,
|
||||
def calc_time_for_light_when_sunset():
|
||||
""" Calculates the time when to start fading lights in when sun sets.
|
||||
Returns None if no next_setting data available. """
|
||||
next_setting = sun.next_setting(statemachine)
|
||||
next_setting = sun.next_setting(hass)
|
||||
|
||||
if next_setting:
|
||||
return (next_setting - LIGHT_TRANSITION_TIME * len(light_ids))
|
||||
return next_setting - LIGHT_TRANSITION_TIME * len(light_ids)
|
||||
else:
|
||||
return None
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def schedule_light_on_sun_rise(entity, old_state, new_state):
|
||||
"""The moment sun sets we want to have all the lights on.
|
||||
We will schedule to have each light start after one another
|
||||
@@ -64,10 +76,9 @@ def setup(bus, statemachine,
|
||||
def turn_light_on_before_sunset(light_id):
|
||||
""" Helper function to turn on lights slowly if there
|
||||
are devices home and the light is not on yet. """
|
||||
if (device_tracker.is_on(statemachine) and
|
||||
not light.is_on(statemachine, light_id)):
|
||||
if device_tracker.is_on(hass) and not light.is_on(hass, light_id):
|
||||
|
||||
light.turn_on(bus, light_id,
|
||||
light.turn_on(hass, light_id,
|
||||
transition=LIGHT_TRANSITION_TIME.seconds,
|
||||
profile=light_profile)
|
||||
|
||||
@@ -81,58 +92,55 @@ def setup(bus, statemachine,
|
||||
|
||||
if start_point:
|
||||
for index, light_id in enumerate(light_ids):
|
||||
ha.track_point_in_time(bus, turn_on(light_id),
|
||||
(start_point +
|
||||
index * LIGHT_TRANSITION_TIME))
|
||||
track_point_in_time(
|
||||
hass, turn_on(light_id),
|
||||
(start_point + index * LIGHT_TRANSITION_TIME))
|
||||
|
||||
# Track every time sun rises so we can schedule a time-based
|
||||
# pre-sun set event
|
||||
ha.track_state_change(bus, sun.ENTITY_ID,
|
||||
schedule_light_on_sun_rise,
|
||||
sun.STATE_BELOW_HORIZON, sun.STATE_ABOVE_HORIZON)
|
||||
track_state_change(hass, sun.ENTITY_ID, schedule_light_on_sun_rise,
|
||||
sun.STATE_BELOW_HORIZON, sun.STATE_ABOVE_HORIZON)
|
||||
|
||||
# If the sun is already above horizon
|
||||
# schedule the time-based pre-sun set event
|
||||
if sun.is_on(statemachine):
|
||||
if sun.is_on(hass):
|
||||
schedule_light_on_sun_rise(None, None, None)
|
||||
|
||||
def check_light_on_dev_state_change(entity, old_state, new_state):
|
||||
""" Function to handle tracked device state changes. """
|
||||
lights_are_on = group.is_on(statemachine, light_group)
|
||||
lights_are_on = group.is_on(hass, light_group)
|
||||
|
||||
light_needed = not (lights_are_on or sun.is_on(statemachine))
|
||||
light_needed = not (lights_are_on or sun.is_on(hass))
|
||||
|
||||
# Specific device came home ?
|
||||
if (entity != device_tracker.ENTITY_ID_ALL_DEVICES and
|
||||
new_state.state == components.STATE_HOME):
|
||||
if entity != device_tracker.ENTITY_ID_ALL_DEVICES and \
|
||||
new_state.state == STATE_HOME:
|
||||
|
||||
# These variables are needed for the elif check
|
||||
now = datetime.now()
|
||||
now = dt_util.now()
|
||||
start_point = calc_time_for_light_when_sunset()
|
||||
|
||||
# Do we need lights?
|
||||
if light_needed:
|
||||
|
||||
logger.info(
|
||||
"Home coming event for {}. Turning lights on".
|
||||
format(entity))
|
||||
"Home coming event for %s. Turning lights on", entity)
|
||||
|
||||
light.turn_on(bus, light_ids,
|
||||
profile=light_profile)
|
||||
light.turn_on(hass, light_ids, profile=light_profile)
|
||||
|
||||
# Are we in the time span were we would turn on the lights
|
||||
# if someone would be home?
|
||||
# Check this by seeing if current time is later then the point
|
||||
# in time when we would start putting the lights on.
|
||||
elif (start_point and
|
||||
start_point < now < sun.next_setting(statemachine)):
|
||||
start_point < now < sun.next_setting(hass)):
|
||||
|
||||
# Check for every light if it would be on if someone was home
|
||||
# when the fading in started and turn it on if so
|
||||
for index, light_id in enumerate(light_ids):
|
||||
|
||||
if now > start_point + index * LIGHT_TRANSITION_TIME:
|
||||
light.turn_on(bus, light_id)
|
||||
light.turn_on(hass, light_id)
|
||||
|
||||
else:
|
||||
# If this light didn't happen to be turned on yet so
|
||||
@@ -140,22 +148,23 @@ def setup(bus, statemachine,
|
||||
break
|
||||
|
||||
# Did all devices leave the house?
|
||||
elif (entity == device_tracker.ENTITY_ID_ALL_DEVICES and
|
||||
new_state.state == components.STATE_NOT_HOME and lights_are_on):
|
||||
elif (entity == device_group and
|
||||
new_state.state == STATE_NOT_HOME and lights_are_on and
|
||||
not disable_turn_off):
|
||||
|
||||
logger.info(
|
||||
"Everyone has left but there are devices on. Turning them off")
|
||||
"Everyone has left but there are lights on. Turning them off")
|
||||
|
||||
light.turn_off(bus)
|
||||
light.turn_off(hass, light_ids)
|
||||
|
||||
# Track home coming of each seperate device
|
||||
for entity in device_entity_ids:
|
||||
ha.track_state_change(bus, entity, check_light_on_dev_state_change,
|
||||
components.STATE_NOT_HOME, components.STATE_HOME)
|
||||
# Track home coming of each device
|
||||
track_state_change(
|
||||
hass, device_entity_ids, check_light_on_dev_state_change,
|
||||
STATE_NOT_HOME, STATE_HOME)
|
||||
|
||||
# Track when all devices are gone to shut down lights
|
||||
ha.track_state_change(bus, device_tracker.ENTITY_ID_ALL_DEVICES,
|
||||
check_light_on_dev_state_change,
|
||||
components.STATE_HOME, components.STATE_NOT_HOME)
|
||||
track_state_change(
|
||||
hass, device_group, check_light_on_dev_state_change,
|
||||
STATE_HOME, STATE_NOT_HOME)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
"""
|
||||
homeassistant.components.tracker
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to keep track of devices.
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
import os
|
||||
import csv
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.util as util
|
||||
import homeassistant.components as components
|
||||
|
||||
from homeassistant.components import group
|
||||
|
||||
DOMAIN = "device_tracker"
|
||||
|
||||
SERVICE_DEVICE_TRACKER_RELOAD = "reload_devices_csv"
|
||||
|
||||
GROUP_NAME_ALL_DEVICES = 'all_tracked_devices'
|
||||
ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format(
|
||||
GROUP_NAME_ALL_DEVICES)
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
# After how much time do we consider a device not home if
|
||||
# it does not show up on scans
|
||||
TIME_SPAN_FOR_ERROR_IN_SCANNING = timedelta(minutes=3)
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
# Filename to save known devices to
|
||||
KNOWN_DEVICES_FILE = "known_devices.csv"
|
||||
|
||||
|
||||
def is_on(statemachine, entity_id=None):
|
||||
""" Returns if any or specified device is home. """
|
||||
entity = entity_id or ENTITY_ID_ALL_DEVICES
|
||||
|
||||
return statemachine.is_state(entity, components.STATE_HOME)
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class DeviceTracker(object):
|
||||
""" Class that tracks which devices are home and which are not. """
|
||||
|
||||
def __init__(self, bus, statemachine, device_scanner, error_scanning=None):
|
||||
self.statemachine = statemachine
|
||||
self.bus = bus
|
||||
self.device_scanner = device_scanner
|
||||
|
||||
self.error_scanning = error_scanning or TIME_SPAN_FOR_ERROR_IN_SCANNING
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Dictionary to keep track of known devices and devices we track
|
||||
self.known_devices = {}
|
||||
|
||||
# Did we encounter an invalid known devices file
|
||||
self.invalid_known_devices_file = False
|
||||
|
||||
self._read_known_devices_file()
|
||||
|
||||
# Wrap it in a func instead of lambda so it can be identified in
|
||||
# the bus by its __name__ attribute.
|
||||
def update_device_state(time): # pylint: disable=unused-argument
|
||||
""" Triggers update of the device states. """
|
||||
self.update_devices()
|
||||
|
||||
ha.track_time_change(bus, update_device_state)
|
||||
|
||||
bus.register_service(DOMAIN,
|
||||
SERVICE_DEVICE_TRACKER_RELOAD,
|
||||
lambda service: self._read_known_devices_file())
|
||||
|
||||
self.update_devices()
|
||||
|
||||
group.setup(bus, statemachine, GROUP_NAME_ALL_DEVICES,
|
||||
list(self.device_entity_ids))
|
||||
|
||||
@property
|
||||
def device_entity_ids(self):
|
||||
""" Returns a set containing all device entity ids
|
||||
that are being tracked. """
|
||||
return set([self.known_devices[device]['entity_id'] for device
|
||||
in self.known_devices
|
||||
if self.known_devices[device]['track']])
|
||||
|
||||
def update_devices(self, found_devices=None):
|
||||
""" Update device states based on the found devices. """
|
||||
self.lock.acquire()
|
||||
|
||||
found_devices = found_devices or self.device_scanner.scan_devices()
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
known_dev = self.known_devices
|
||||
|
||||
temp_tracking_devices = [device for device in known_dev
|
||||
if known_dev[device]['track']]
|
||||
|
||||
for device in found_devices:
|
||||
# Are we tracking this device?
|
||||
if device in temp_tracking_devices:
|
||||
temp_tracking_devices.remove(device)
|
||||
|
||||
known_dev[device]['last_seen'] = now
|
||||
|
||||
self.statemachine.set_state(
|
||||
known_dev[device]['entity_id'], components.STATE_HOME)
|
||||
|
||||
# For all devices we did not find, set state to NH
|
||||
# But only if they have been gone for longer then the error time span
|
||||
# Because we do not want to have stuff happening when the device does
|
||||
# not show up for 1 scan beacuse of reboot etc
|
||||
for device in temp_tracking_devices:
|
||||
if (now - known_dev[device]['last_seen'] > self.error_scanning):
|
||||
|
||||
self.statemachine.set_state(known_dev[device]['entity_id'],
|
||||
components.STATE_NOT_HOME)
|
||||
|
||||
# If we come along any unknown devices we will write them to the
|
||||
# known devices file but only if we did not encounter an invalid
|
||||
# known devices file
|
||||
if not self.invalid_known_devices_file:
|
||||
|
||||
unknown_devices = [device for device in found_devices
|
||||
if device not in known_dev]
|
||||
|
||||
if unknown_devices:
|
||||
try:
|
||||
# If file does not exist we will write the header too
|
||||
is_new_file = not os.path.isfile(KNOWN_DEVICES_FILE)
|
||||
|
||||
with open(KNOWN_DEVICES_FILE, 'a') as outp:
|
||||
self.logger.info((
|
||||
"DeviceTracker:Found {} new devices,"
|
||||
" updating {}").format(len(unknown_devices),
|
||||
KNOWN_DEVICES_FILE))
|
||||
|
||||
writer = csv.writer(outp)
|
||||
|
||||
if is_new_file:
|
||||
writer.writerow(("device", "name", "track"))
|
||||
|
||||
for device in unknown_devices:
|
||||
# See if the device scanner knows the name
|
||||
# else defaults to unknown device
|
||||
name = (self.device_scanner.get_device_name(device)
|
||||
or "unknown_device")
|
||||
|
||||
writer.writerow((device, name, 0))
|
||||
known_dev[device] = {'name': name,
|
||||
'track': False}
|
||||
|
||||
except IOError:
|
||||
self.logger.exception((
|
||||
"DeviceTracker:Error updating {}"
|
||||
"with {} new devices").format(
|
||||
KNOWN_DEVICES_FILE, len(unknown_devices)))
|
||||
|
||||
self.lock.release()
|
||||
|
||||
def _read_known_devices_file(self):
|
||||
""" Parse and process the known devices file. """
|
||||
|
||||
# Read known devices if file exists
|
||||
if os.path.isfile(KNOWN_DEVICES_FILE):
|
||||
self.lock.acquire()
|
||||
|
||||
known_devices = {}
|
||||
|
||||
with open(KNOWN_DEVICES_FILE) as inp:
|
||||
default_last_seen = datetime(1990, 1, 1)
|
||||
|
||||
# Temp variable to keep track of which entity ids we use
|
||||
# so we can ensure we have unique entity ids.
|
||||
used_entity_ids = []
|
||||
|
||||
try:
|
||||
for row in csv.DictReader(inp):
|
||||
device = row['device']
|
||||
|
||||
row['track'] = True if row['track'] == '1' else False
|
||||
|
||||
# If we track this device setup tracking variables
|
||||
if row['track']:
|
||||
row['last_seen'] = default_last_seen
|
||||
|
||||
# Make sure that each device is mapped
|
||||
# to a unique entity_id name
|
||||
name = util.slugify(row['name']) if row['name'] \
|
||||
else "unnamed_device"
|
||||
|
||||
entity_id = ENTITY_ID_FORMAT.format(name)
|
||||
tries = 1
|
||||
|
||||
while entity_id in used_entity_ids:
|
||||
tries += 1
|
||||
|
||||
suffix = "_{}".format(tries)
|
||||
|
||||
entity_id = ENTITY_ID_FORMAT.format(
|
||||
name + suffix)
|
||||
|
||||
row['entity_id'] = entity_id
|
||||
used_entity_ids.append(entity_id)
|
||||
|
||||
known_devices[device] = row
|
||||
|
||||
if not known_devices:
|
||||
self.logger.warning(
|
||||
"No devices to track. Please update {}.".format(
|
||||
KNOWN_DEVICES_FILE))
|
||||
|
||||
# Remove entities that are no longer maintained
|
||||
new_entity_ids = set([known_devices[device]['entity_id']
|
||||
for device in known_devices
|
||||
if known_devices[device]['track']])
|
||||
|
||||
for entity_id in \
|
||||
self.device_entity_ids - new_entity_ids:
|
||||
|
||||
self.logger.info(
|
||||
"DeviceTracker:Removing entity {}".format(
|
||||
entity_id))
|
||||
self.statemachine.remove_entity(entity_id)
|
||||
|
||||
# File parsed, warnings given if necessary
|
||||
# entities cleaned up, make it available
|
||||
self.known_devices = known_devices
|
||||
|
||||
self.logger.info(
|
||||
"DeviceTracker:Loaded devices from {}".format(
|
||||
KNOWN_DEVICES_FILE))
|
||||
|
||||
except KeyError:
|
||||
self.invalid_known_devices_file = True
|
||||
self.logger.warning((
|
||||
"Invalid {} found. "
|
||||
"We won't update it with new found devices.").
|
||||
format(KNOWN_DEVICES_FILE))
|
||||
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
|
||||
class TomatoDeviceScanner(object):
|
||||
""" This class queries a wireless router running Tomato firmware
|
||||
for connected devices.
|
||||
|
||||
A description of the Tomato API can be found on
|
||||
http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/
|
||||
"""
|
||||
|
||||
def __init__(self, host, username, password, http_id):
|
||||
self.req = requests.Request('POST',
|
||||
'http://{}/update.cgi'.format(host),
|
||||
data={'_http_id': http_id,
|
||||
'exec': 'devlist'},
|
||||
auth=requests.auth.HTTPBasicAuth(
|
||||
username, password)).prepare()
|
||||
|
||||
self.parse_api_pattern = re.compile(r"(?P<param>\w*) = (?P<value>.*);")
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.date_updated = None
|
||||
self.last_results = {"wldev": [], "dhcpd_lease": []}
|
||||
|
||||
self.success_init = self._update_tomato_info()
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
|
||||
self._update_tomato_info()
|
||||
|
||||
return [item[1] for item in self.last_results['wldev']]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
# Make sure there are results
|
||||
if not self.date_updated:
|
||||
self._update_tomato_info()
|
||||
|
||||
filter_named = [item[0] for item in self.last_results['dhcpd_lease']
|
||||
if item[2] == device]
|
||||
|
||||
if not filter_named or not filter_named[0]:
|
||||
return None
|
||||
else:
|
||||
return filter_named[0]
|
||||
|
||||
def _update_tomato_info(self):
|
||||
""" Ensures the information from the Tomato router is up to date.
|
||||
Returns boolean if scanning successful. """
|
||||
|
||||
self.lock.acquire()
|
||||
|
||||
# if date_updated is None or the date is too old we scan for new data
|
||||
if (not self.date_updated or datetime.now() - self.date_updated >
|
||||
MIN_TIME_BETWEEN_SCANS):
|
||||
|
||||
self.logger.info("Tomato:Scanning")
|
||||
|
||||
try:
|
||||
response = requests.Session().send(self.req, timeout=3)
|
||||
|
||||
# Calling and parsing the Tomato api here. We only need the
|
||||
# wldev and dhcpd_lease values. For API description see:
|
||||
# http://paulusschoutsen.nl/
|
||||
# blog/2013/10/tomato-api-documentation/
|
||||
if response.status_code == 200:
|
||||
|
||||
for param, value in \
|
||||
self.parse_api_pattern.findall(response.text):
|
||||
|
||||
if param == 'wldev' or param == 'dhcpd_lease':
|
||||
self.last_results[param] = \
|
||||
json.loads(value.replace("'", '"'))
|
||||
|
||||
self.date_updated = datetime.now()
|
||||
|
||||
return True
|
||||
|
||||
elif response.status_code == 401:
|
||||
# Authentication error
|
||||
self.logger.exception((
|
||||
"Tomato:Failed to authenticate, "
|
||||
"please check your username and password"))
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
# We get this if we could not connect to the router or
|
||||
# an invalid http_id was supplied
|
||||
self.logger.exception((
|
||||
"Tomato:Failed to connect to the router"
|
||||
" or invalid http_id supplied"))
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
# We get this if we could not connect to the router or
|
||||
# an invalid http_id was supplied
|
||||
self.logger.exception(
|
||||
"Tomato:Connection to the router timed out")
|
||||
|
||||
return False
|
||||
|
||||
except ValueError:
|
||||
# If json decoder could not parse the response
|
||||
self.logger.exception(
|
||||
"Tomato:Failed to parse response from router")
|
||||
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
else:
|
||||
# We acquired the lock before the IF check,
|
||||
# release it before we return True
|
||||
self.lock.release()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class NetgearDeviceScanner(object):
|
||||
""" This class queries a Netgear wireless router using the SOAP-api. """
|
||||
|
||||
def __init__(self, host, username, password):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.date_updated = None
|
||||
self.last_results = []
|
||||
|
||||
try:
|
||||
import homeassistant.external.pynetgear as pynetgear
|
||||
except ImportError:
|
||||
self.logger.exception(
|
||||
("Netgear:Failed to import pynetgear. "
|
||||
"Did you maybe not cloned the git submodules?"))
|
||||
|
||||
self.success_init = False
|
||||
|
||||
return
|
||||
|
||||
self._api = pynetgear.Netgear(host, username, password)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.logger.info("Netgear:Logging in")
|
||||
if self._api.login():
|
||||
self.success_init = True
|
||||
self._update_info()
|
||||
|
||||
else:
|
||||
self.logger.error("Netgear:Failed to Login")
|
||||
|
||||
self.success_init = False
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
|
||||
self._update_info()
|
||||
|
||||
return [device.mac for device in self.last_results]
|
||||
|
||||
def get_device_name(self, mac):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
# Make sure there are results
|
||||
if not self.date_updated:
|
||||
self._update_info()
|
||||
|
||||
filter_named = [device.name for device in self.last_results
|
||||
if device.mac == mac]
|
||||
|
||||
if filter_named:
|
||||
return filter_named[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
def _update_info(self):
|
||||
""" Retrieves latest information from the Netgear router.
|
||||
Returns boolean if scanning successful. """
|
||||
if not self.success_init:
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
# if date_updated is None or the date is too old we scan for
|
||||
# new data
|
||||
if (not self.date_updated or datetime.now() - self.date_updated >
|
||||
MIN_TIME_BETWEEN_SCANS):
|
||||
|
||||
self.logger.info("Netgear:Scanning")
|
||||
|
||||
self.last_results = self._api.get_attached_devices()
|
||||
|
||||
self.date_updated = datetime.now()
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
return
|
||||
347
homeassistant/components/device_tracker/__init__.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
homeassistant.components.tracker
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to keep track of devices.
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
import os
|
||||
import csv
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.helpers.entity import _OVERWRITE
|
||||
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
|
||||
|
||||
DOMAIN = "device_tracker"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_DEVICE_TRACKER_RELOAD = "reload_devices_csv"
|
||||
|
||||
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)
|
||||
|
||||
# Filename to save known devices to
|
||||
KNOWN_DEVICES_FILE = "known_devices.csv"
|
||||
|
||||
CONF_SECONDS = "interval_seconds"
|
||||
|
||||
DEFAULT_CONF_SECONDS = 12
|
||||
|
||||
TRACK_NEW_DEVICES = "track_new_devices"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_on(hass, entity_id=None):
|
||||
""" Returns if any or specified device is home. """
|
||||
entity = entity_id or ENTITY_ID_ALL_DEVICES
|
||||
|
||||
return hass.states.is_state(entity, STATE_HOME)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Sets up the device tracker. """
|
||||
|
||||
if not validate_config(config, {DOMAIN: [CONF_PLATFORM]}, _LOGGER):
|
||||
return False
|
||||
|
||||
tracker_type = config[DOMAIN].get(CONF_PLATFORM)
|
||||
|
||||
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):
|
||||
return
|
||||
|
||||
try:
|
||||
found_devices = set(dev.upper() for dev in
|
||||
self.device_scanner.scan_devices())
|
||||
|
||||
for device in self.tracked:
|
||||
is_home = device in found_devices
|
||||
|
||||
self._update_state(now, device, is_home)
|
||||
|
||||
if is_home:
|
||||
found_devices.remove(device)
|
||||
|
||||
# Did we find any devices that we didn't know about yet?
|
||||
new_devices = found_devices - self.untracked_devices
|
||||
|
||||
if new_devices:
|
||||
if not self.track_new_devices:
|
||||
self.untracked_devices.update(new_devices)
|
||||
|
||||
self._update_known_devices_file(new_devices)
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
# 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)
|
||||
|
||||
# Return if no known devices file exists
|
||||
if not os.path.isfile(known_dev_path):
|
||||
return
|
||||
|
||||
self.lock.acquire()
|
||||
|
||||
self.untracked_devices.clear()
|
||||
|
||||
with open(known_dev_path) as inp:
|
||||
|
||||
# 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())
|
||||
|
||||
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)
|
||||
|
||||
self._track_device(device, row['name'])
|
||||
|
||||
# Update state_attr with latest from file
|
||||
state_attr = {
|
||||
ATTR_FRIENDLY_NAME: row['name']
|
||||
}
|
||||
|
||||
if row['picture']:
|
||||
state_attr[ATTR_ENTITY_PICTURE] = row['picture']
|
||||
|
||||
self.tracked[device]['state_attr'] = state_attr
|
||||
|
||||
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']
|
||||
|
||||
_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
|
||||
191
homeassistant/components/device_tracker/actiontec.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.actiontec
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning an Actiontec MI424WR
|
||||
(Verizon FIOS) router for device presence.
|
||||
|
||||
This device tracker needs telnet to be enabled on the router.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Actiontec tracker you will need to add something like the
|
||||
following to your configuration.yaml file. If you experience disconnects
|
||||
you can modify the home_interval variable.
|
||||
|
||||
device_tracker:
|
||||
platform: actiontec
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
# optional:
|
||||
home_interval: 10
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
|
||||
home_interval
|
||||
*Optional
|
||||
If the home_interval is set then the component will not let a device
|
||||
be AWAY if it has been HOME in the last home_interval minutes. This is
|
||||
in addition to the 3 minute wait built into the device_tracker component.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from collections import namedtuple
|
||||
import re
|
||||
import threading
|
||||
import telnetlib
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle, convert
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
# interval in minutes to exclude devices from a scan while they are home
|
||||
CONF_HOME_INTERVAL = "home_interval"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_LEASES_REGEX = re.compile(
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})' +
|
||||
r'\smac:\s(?P<mac>([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns an Actiontec scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = ActiontecDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
Device = namedtuple("Device", ["mac", "ip", "last_update"])
|
||||
|
||||
|
||||
class ActiontecDeviceScanner(object):
|
||||
"""
|
||||
This class queries a an actiontec router for connected devices.
|
||||
Adapted from DD-WRT scanner.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0)
|
||||
self.home_interval = timedelta(minutes=minutes)
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = []
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_actiontec_data()
|
||||
self.success_init = data is not None
|
||||
_LOGGER.info("actiontec scanner initialized")
|
||||
if self.home_interval:
|
||||
_LOGGER.info("home_interval set to: %s", self.home_interval)
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client.mac for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client.mac == device:
|
||||
return client.ip
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the Actiontec MI424WR router is up
|
||||
to date. Returns boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.info("Scanning")
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
exclude_targets = set()
|
||||
exclude_target_list = []
|
||||
now = dt_util.now()
|
||||
if self.home_interval:
|
||||
for host in self.last_results:
|
||||
if host.last_update + self.home_interval > now:
|
||||
exclude_targets.add(host)
|
||||
if len(exclude_targets) > 0:
|
||||
exclude_target_list = [t.ip for t in exclude_targets]
|
||||
|
||||
actiontec_data = self.get_actiontec_data()
|
||||
if not actiontec_data:
|
||||
return False
|
||||
self.last_results = []
|
||||
for client in exclude_target_list:
|
||||
if client in actiontec_data:
|
||||
actiontec_data.pop(client)
|
||||
for name, data in actiontec_data.items():
|
||||
device = Device(data['mac'], name, now)
|
||||
self.last_results.append(device)
|
||||
self.last_results.extend(exclude_targets)
|
||||
_LOGGER.info("actiontec scan successful")
|
||||
return True
|
||||
|
||||
def get_actiontec_data(self):
|
||||
""" Retrieve data from Actiontec MI424WR and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'Username: ')
|
||||
telnet.write((self.username + '\n').encode('ascii'))
|
||||
telnet.read_until(b'Password: ')
|
||||
telnet.write((self.password + '\n').encode('ascii'))
|
||||
prompt = telnet.read_until(
|
||||
b'Wireless Broadband Router> ').split(b'\n')[-1]
|
||||
telnet.write('firewall mac_cache_dump\n'.encode('ascii'))
|
||||
telnet.write('\n'.encode('ascii'))
|
||||
telnet.read_until(prompt)
|
||||
leases_result = telnet.read_until(prompt).split(b'\n')[1:-1]
|
||||
telnet.write('exit\n'.encode('ascii'))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
" is telnet enabled?")
|
||||
return None
|
||||
|
||||
devices = {}
|
||||
for lease in leases_result:
|
||||
match = _LEASES_REGEX.search(lease.decode('utf-8'))
|
||||
if match is not None:
|
||||
devices[match.group('ip')] = {
|
||||
'ip': match.group('ip'),
|
||||
'mac': match.group('mac').upper()
|
||||
}
|
||||
return devices
|
||||
148
homeassistant/components/device_tracker/aruba.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.aruba
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a Aruba Access Point for device
|
||||
presence.
|
||||
|
||||
This device tracker needs telnet to be enabled on the router.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Aruba tracker you will need to add something like the following
|
||||
to your configuration.yaml file. You also need to enable Telnet in the
|
||||
configuration page of your router.
|
||||
|
||||
device_tracker:
|
||||
platform: aruba
|
||||
host: YOUR_ACCESS_POINT_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import telnetlib
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEVICES_REGEX = re.compile(
|
||||
r'(?P<name>([^\s]+))\s+' +
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' +
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Aruba scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = ArubaDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class ArubaDeviceScanner(object):
|
||||
""" This class queries a Aruba Acces Point for connected devices. """
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_aruba_data()
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device IDs.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client['mac'] == device:
|
||||
return client['name']
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the Aruba Access Point is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
data = self.get_aruba_data()
|
||||
if not data:
|
||||
return False
|
||||
|
||||
self.last_results = data.values()
|
||||
return True
|
||||
|
||||
def get_aruba_data(self):
|
||||
""" Retrieve data from Aruba Access Point and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'User: ')
|
||||
telnet.write((self.username + '\r\n').encode('ascii'))
|
||||
telnet.read_until(b'Password: ')
|
||||
telnet.write((self.password + '\r\n').encode('ascii'))
|
||||
telnet.read_until(b'#')
|
||||
telnet.write(('show clients\r\n').encode('ascii'))
|
||||
devices_result = telnet.read_until(b'#').split(b'\r\n')
|
||||
telnet.write('exit\r\n'.encode('ascii'))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
" is telnet enabled?")
|
||||
return
|
||||
|
||||
devices = {}
|
||||
for device in devices_result:
|
||||
match = _DEVICES_REGEX.search(device.decode('utf-8'))
|
||||
if match:
|
||||
devices[match.group('ip')] = {
|
||||
'ip': match.group('ip'),
|
||||
'mac': match.group('mac').upper(),
|
||||
'name': match.group('name')
|
||||
}
|
||||
return devices
|
||||
171
homeassistant/components/device_tracker/asuswrt.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.asuswrt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a ASUSWRT router for device
|
||||
presence.
|
||||
|
||||
This device tracker needs telnet to be enabled on the router.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the ASUSWRT tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: asuswrt
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import telnetlib
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_LEASES_REGEX = re.compile(
|
||||
r'\w+\s' +
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
|
||||
r'(?P<host>([^\s]+))')
|
||||
|
||||
_IP_NEIGH_REGEX = re.compile(
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
|
||||
r'\w+\s' +
|
||||
r'\w+\s' +
|
||||
r'(\w+\s(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' +
|
||||
r'(?P<status>(\w+))')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns an ASUS-WRT scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = AsusWrtDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class AsusWrtDeviceScanner(object):
|
||||
"""
|
||||
This class queries a router running ASUSWRT firmware
|
||||
for connected devices. Adapted from DD-WRT scanner.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_asuswrt_data()
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device IDs.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client['mac'] == device:
|
||||
return client['host']
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the ASUSWRT router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Checking ARP")
|
||||
data = self.get_asuswrt_data()
|
||||
if not data:
|
||||
return False
|
||||
|
||||
active_clients = [client for client in data.values() if
|
||||
client['status'] == 'REACHABLE' or
|
||||
client['status'] == 'DELAY' or
|
||||
client['status'] == 'STALE']
|
||||
self.last_results = active_clients
|
||||
return True
|
||||
|
||||
def get_asuswrt_data(self):
|
||||
""" Retrieve data from ASUSWRT and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'login: ')
|
||||
telnet.write((self.username + '\n').encode('ascii'))
|
||||
telnet.read_until(b'Password: ')
|
||||
telnet.write((self.password + '\n').encode('ascii'))
|
||||
prompt_string = telnet.read_until(b'#').split(b'\n')[-1]
|
||||
telnet.write('ip neigh\n'.encode('ascii'))
|
||||
neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1]
|
||||
telnet.write('cat /var/lib/misc/dnsmasq.leases\n'.encode('ascii'))
|
||||
leases_result = telnet.read_until(prompt_string).split(b'\n')[1:-1]
|
||||
telnet.write('exit\n'.encode('ascii'))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
" is telnet enabled?")
|
||||
return
|
||||
|
||||
devices = {}
|
||||
for lease in leases_result:
|
||||
match = _LEASES_REGEX.search(lease.decode('utf-8'))
|
||||
devices[match.group('ip')] = {
|
||||
'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 match.group('ip') in devices:
|
||||
devices[match.group('ip')]['status'] = match.group('status')
|
||||
return devices
|
||||
194
homeassistant/components/device_tracker/ddwrt.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.ddwrt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a DD-WRT router for device
|
||||
presence.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the DD-WRT tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: ddwrt
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a DD-WRT scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = DdWrtDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class DdWrtDeviceScanner(object):
|
||||
"""
|
||||
This class queries a wireless router running DD-WRT firmware
|
||||
for connected devices. Adapted from Tomato scanner.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
self.mac2name = None
|
||||
|
||||
# Test the router is accessible
|
||||
url = 'http://{}/Status_Wireless.live.asp'.format(self.host)
|
||||
data = self.get_ddwrt_data(url)
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
return self.last_results
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
with self.lock:
|
||||
# if not initialised and not already scanned and not found
|
||||
if self.mac2name is None or device not in self.mac2name:
|
||||
url = 'http://{}/Status_Lan.live.asp'.format(self.host)
|
||||
data = self.get_ddwrt_data(url)
|
||||
|
||||
if not data:
|
||||
return
|
||||
|
||||
dhcp_leases = data.get('dhcp_leases', None)
|
||||
if dhcp_leases:
|
||||
# remove leading and trailing single quotes
|
||||
cleaned_str = dhcp_leases.strip().strip('"')
|
||||
elements = cleaned_str.split('","')
|
||||
num_clients = int(len(elements)/5)
|
||||
self.mac2name = {}
|
||||
for idx in range(0, num_clients):
|
||||
# this is stupid but the data is a single array
|
||||
# every 5 elements represents one hosts, the MAC
|
||||
# is the third element and the name is the first
|
||||
mac_index = (idx * 5) + 2
|
||||
if mac_index < len(elements):
|
||||
mac = elements[mac_index]
|
||||
self.mac2name[mac] = elements[idx * 5]
|
||||
|
||||
return self.mac2name.get(device, None)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the DD-WRT router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Checking ARP")
|
||||
|
||||
url = 'http://{}/Status_Wireless.live.asp'.format(self.host)
|
||||
data = self.get_ddwrt_data(url)
|
||||
|
||||
if not data:
|
||||
return False
|
||||
|
||||
if data:
|
||||
self.last_results = []
|
||||
active_clients = data.get('active_wireless', None)
|
||||
if active_clients:
|
||||
# This is really lame, instead of using JSON the DD-WRT UI
|
||||
# uses its own data format for some reason and then
|
||||
# regex's out values so I guess I have to do the same,
|
||||
# LAME!!!
|
||||
|
||||
# remove leading and trailing single quotes
|
||||
clean_str = active_clients.strip().strip("'")
|
||||
elements = clean_str.split("','")
|
||||
|
||||
num_clients = int(len(elements)/9)
|
||||
for idx in range(0, num_clients):
|
||||
# get every 9th element which is the MAC address
|
||||
index = idx * 9
|
||||
if index < len(elements):
|
||||
self.last_results.append(elements[index])
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_ddwrt_data(self, url):
|
||||
""" Retrieve data from DD-WRT and return parsed result. """
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
auth=(self.username, self.password),
|
||||
timeout=4)
|
||||
except requests.exceptions.Timeout:
|
||||
_LOGGER.exception("Connection to the router timed out")
|
||||
return
|
||||
if response.status_code == 200:
|
||||
return _parse_ddwrt_response(response.text)
|
||||
elif response.status_code == 401:
|
||||
# Authentication error
|
||||
_LOGGER.exception(
|
||||
"Failed to authenticate, "
|
||||
"please check your username and password")
|
||||
return
|
||||
else:
|
||||
_LOGGER.error("Invalid response from ddwrt: %s", response)
|
||||
|
||||
|
||||
def _parse_ddwrt_response(data_str):
|
||||
""" Parse the DD-WRT data format. """
|
||||
return {
|
||||
key: val for key, val in _DDWRT_DATA_REGEX
|
||||
.findall(data_str)}
|
||||
186
homeassistant/components/device_tracker/luci.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.luci
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a OpenWRT router for device
|
||||
presence.
|
||||
|
||||
It's required that the luci RPC package is installed on the OpenWRT router:
|
||||
# opkg install luci-mod-rpc
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Luci tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: luci
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Luci scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = LuciDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class LuciDeviceScanner(object):
|
||||
"""
|
||||
This class queries a wireless router running OpenWrt firmware
|
||||
for connected devices. Adapted from Tomato scanner.
|
||||
|
||||
# opkg install luci-mod-rpc
|
||||
for this to work on the router.
|
||||
|
||||
The API is described here:
|
||||
http://luci.subsignal.org/trac/wiki/Documentation/JsonRpcHowTo
|
||||
|
||||
(Currently, we do only wifi iwscan, and no DHCP lease access.)
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
host = config[CONF_HOST]
|
||||
username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
|
||||
|
||||
self.parse_api_pattern = re.compile(r"(?P<param>\w*) = (?P<value>.*);")
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
self.token = _get_token(host, username, password)
|
||||
self.host = host
|
||||
|
||||
self.mac2name = None
|
||||
self.success_init = self.token is not None
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
return self.last_results
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
with self.lock:
|
||||
if self.mac2name is None:
|
||||
url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host)
|
||||
result = _req_json_rpc(url, 'get_all', 'dhcp',
|
||||
params={'auth': self.token})
|
||||
if result:
|
||||
hosts = [x for x in result.values()
|
||||
if x['.type'] == 'host' and
|
||||
'mac' in x and 'name' in x]
|
||||
mac2name_list = [
|
||||
(x['mac'].upper(), x['name']) for x in hosts]
|
||||
self.mac2name = dict(mac2name_list)
|
||||
else:
|
||||
# Error, handled in the _req_json_rpc
|
||||
return
|
||||
return self.mac2name.get(device.upper(), None)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the Luci router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Checking ARP")
|
||||
|
||||
url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host)
|
||||
result = _req_json_rpc(url, 'net.arptable',
|
||||
params={'auth': self.token})
|
||||
if result:
|
||||
self.last_results = []
|
||||
for device_entry in result:
|
||||
# Check if the Flags for each device contain
|
||||
# NUD_REACHABLE and if so, add it to last_results
|
||||
if int(device_entry['Flags'], 16) & 0x2:
|
||||
self.last_results.append(device_entry['HW address'])
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _req_json_rpc(url, method, *args, **kwargs):
|
||||
""" Perform one JSON RPC operation. """
|
||||
data = json.dumps({'method': method, 'params': args})
|
||||
try:
|
||||
res = requests.post(url, data=data, timeout=5, **kwargs)
|
||||
except requests.exceptions.Timeout:
|
||||
_LOGGER.exception("Connection to the router timed out")
|
||||
return
|
||||
if res.status_code == 200:
|
||||
try:
|
||||
result = res.json()
|
||||
except ValueError:
|
||||
# If json decoder could not parse the response
|
||||
_LOGGER.exception("Failed to parse response from luci")
|
||||
return
|
||||
try:
|
||||
return result['result']
|
||||
except KeyError:
|
||||
_LOGGER.exception("No result in response from luci")
|
||||
return
|
||||
elif res.status_code == 401:
|
||||
# Authentication error
|
||||
_LOGGER.exception(
|
||||
"Failed to authenticate, "
|
||||
"please check your username and password")
|
||||
return
|
||||
else:
|
||||
_LOGGER.error("Invalid response from luci: %s", res)
|
||||
|
||||
|
||||
def _get_token(host, username, password):
|
||||
""" Get authentication token for the given host+username+password. """
|
||||
url = 'http://{}/cgi-bin/luci/rpc/auth'.format(host)
|
||||
return _req_json_rpc(url, 'login', username, password)
|
||||
119
homeassistant/components/device_tracker/netgear.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.netgear
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a Netgear router for device
|
||||
presence.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Netgear tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: netgear
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import threading
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['pynetgear==0.3']
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Netgear scanner. """
|
||||
info = config[DOMAIN]
|
||||
host = info.get(CONF_HOST)
|
||||
username = info.get(CONF_USERNAME)
|
||||
password = info.get(CONF_PASSWORD)
|
||||
|
||||
if password is not None and host is None:
|
||||
_LOGGER.warning('Found username or password but no host')
|
||||
return None
|
||||
|
||||
scanner = NetgearDeviceScanner(host, username, password)
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class NetgearDeviceScanner(object):
|
||||
""" This class queries a Netgear wireless router using the SOAP-API. """
|
||||
|
||||
def __init__(self, host, username, password):
|
||||
import pynetgear
|
||||
|
||||
self.last_results = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
if host is None:
|
||||
print("BIER")
|
||||
self._api = pynetgear.Netgear()
|
||||
elif username is None:
|
||||
self._api = pynetgear.Netgear(password, host)
|
||||
else:
|
||||
self._api = pynetgear.Netgear(password, host, username)
|
||||
|
||||
_LOGGER.info("Logging in")
|
||||
|
||||
results = self._api.get_attached_devices()
|
||||
|
||||
self.success_init = results is not None
|
||||
|
||||
if self.success_init:
|
||||
self.last_results = results
|
||||
else:
|
||||
_LOGGER.error("Failed to Login")
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
self._update_info()
|
||||
|
||||
return (device.mac for device in self.last_results)
|
||||
|
||||
def get_device_name(self, mac):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
try:
|
||||
return next(device.name for device in self.last_results
|
||||
if device.mac == mac)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Retrieves latest information from the Netgear router.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Scanning")
|
||||
|
||||
self.last_results = self._api.get_attached_devices() or []
|
||||
150
homeassistant/components/device_tracker/nmap_tracker.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.nmap
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a network with nmap.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the nmap tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: nmap_tracker
|
||||
hosts: 192.168.1.1/24
|
||||
|
||||
Variables:
|
||||
|
||||
hosts
|
||||
*Required
|
||||
The IP addresses to scan in the network-prefix notation (192.168.1.1/24) or
|
||||
the range notation (192.168.1.1-255).
|
||||
|
||||
home_interval
|
||||
*Optional
|
||||
Number of minutes it will not scan devices that it found in previous results.
|
||||
This is to save battery.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from collections import namedtuple
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import CONF_HOSTS
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle, convert
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# interval in minutes to exclude devices from a scan while they are home
|
||||
CONF_HOME_INTERVAL = "home_interval"
|
||||
|
||||
REQUIREMENTS = ['python-nmap==0.4.1']
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Nmap scanner. """
|
||||
if not validate_config(config, {DOMAIN: [CONF_HOSTS]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = NmapDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
Device = namedtuple("Device", ["mac", "name", "ip", "last_update"])
|
||||
|
||||
|
||||
def _arp(ip_address):
|
||||
""" Get the MAC address for a given IP. """
|
||||
cmd = ['arp', '-n', ip_address]
|
||||
arp = subprocess.Popen(cmd, stdout=subprocess.PIPE)
|
||||
out, _ = arp.communicate()
|
||||
match = re.search(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})', str(out))
|
||||
if match:
|
||||
return match.group(0)
|
||||
_LOGGER.info("No MAC address found for %s", ip_address)
|
||||
return None
|
||||
|
||||
|
||||
class NmapDeviceScanner(object):
|
||||
""" This class scans for devices using nmap. """
|
||||
|
||||
def __init__(self, config):
|
||||
self.last_results = []
|
||||
|
||||
self.hosts = config[CONF_HOSTS]
|
||||
minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0)
|
||||
self.home_interval = timedelta(minutes=minutes)
|
||||
|
||||
self.success_init = self._update_info()
|
||||
_LOGGER.info("nmap scanner initialized")
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
return [device.mac for device in self.last_results]
|
||||
|
||||
def get_device_name(self, mac):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
filter_named = [device.name for device in self.last_results
|
||||
if device.mac == mac]
|
||||
|
||||
if filter_named:
|
||||
return filter_named[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Scans the network for devices.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.info("Scanning")
|
||||
|
||||
from nmap import PortScanner, PortScannerError
|
||||
scanner = PortScanner()
|
||||
|
||||
options = "-F --host-timeout 5"
|
||||
exclude_targets = set()
|
||||
if self.home_interval:
|
||||
now = dt_util.now()
|
||||
for host in self.last_results:
|
||||
if host.last_update + self.home_interval > now:
|
||||
exclude_targets.add(host)
|
||||
if len(exclude_targets) > 0:
|
||||
target_list = [t.ip for t in exclude_targets]
|
||||
options += " --exclude {}".format(",".join(target_list))
|
||||
|
||||
try:
|
||||
result = scanner.scan(hosts=self.hosts, arguments=options)
|
||||
except PortScannerError:
|
||||
return False
|
||||
|
||||
now = dt_util.now()
|
||||
self.last_results = []
|
||||
for ipv4, info in result['scan'].items():
|
||||
if info['status']['state'] != 'up':
|
||||
continue
|
||||
name = info['hostnames'][0] if info['hostnames'] else ipv4
|
||||
# Mac address only returned if nmap ran as root
|
||||
mac = info['addresses'].get('mac') or _arp(ipv4)
|
||||
if mac is None:
|
||||
continue
|
||||
device = Device(mac.upper(), name, ipv4, now)
|
||||
self.last_results.append(device)
|
||||
self.last_results.extend(exclude_targets)
|
||||
|
||||
_LOGGER.info("nmap scan successful")
|
||||
return True
|
||||
160
homeassistant/components/device_tracker/thomson.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.thomson
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a THOMSON router for device
|
||||
presence.
|
||||
|
||||
This device tracker needs telnet to be enabled on the router.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the THOMSON tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: thomson
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import telnetlib
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEVICES_REGEX = re.compile(
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' +
|
||||
r'(?P<status>([^\s]+))\s+' +
|
||||
r'(?P<type>([^\s]+))\s+' +
|
||||
r'(?P<intf>([^\s]+))\s+' +
|
||||
r'(?P<hwintf>([^\s]+))\s+' +
|
||||
r'(?P<host>([^\s]+))')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a THOMSON scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = ThomsonDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class ThomsonDeviceScanner(object):
|
||||
"""
|
||||
This class queries a router running THOMSON firmware
|
||||
for connected devices. Adapted from ASUSWRT scanner.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
# Test the router is accessible
|
||||
data = self.get_thomson_data()
|
||||
self.success_init = data is not None
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
|
||||
self._update_info()
|
||||
return [client['mac'] for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device
|
||||
or None if we don't know. """
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client['mac'] == device:
|
||||
return client['host']
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the THOMSON router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Checking ARP")
|
||||
data = self.get_thomson_data()
|
||||
if not data:
|
||||
return False
|
||||
|
||||
# flag C stands for CONNECTED
|
||||
active_clients = [client for client in data.values() if
|
||||
client['status'].find('C') != -1]
|
||||
self.last_results = active_clients
|
||||
return True
|
||||
|
||||
def get_thomson_data(self):
|
||||
""" Retrieve data from THOMSON and return parsed result. """
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'Username : ')
|
||||
telnet.write((self.username + '\r\n').encode('ascii'))
|
||||
telnet.read_until(b'Password : ')
|
||||
telnet.write((self.password + '\r\n').encode('ascii'))
|
||||
telnet.read_until(b'=>')
|
||||
telnet.write(('hostmgr list\r\n').encode('ascii'))
|
||||
devices_result = telnet.read_until(b'=>').split(b'\r\n')
|
||||
telnet.write('exit\r\n'.encode('ascii'))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
" is telnet enabled?")
|
||||
return
|
||||
|
||||
devices = {}
|
||||
for device in devices_result:
|
||||
match = _DEVICES_REGEX.search(device.decode('utf-8'))
|
||||
if match:
|
||||
devices[match.group('ip')] = {
|
||||
'ip': match.group('ip'),
|
||||
'mac': match.group('mac').upper(),
|
||||
'host': match.group('host'),
|
||||
'status': match.group('status')
|
||||
}
|
||||
return devices
|
||||
173
homeassistant/components/device_tracker/tomato.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.tomato
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a Tomato router for device
|
||||
presence.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Tomato tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: tomato
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
http_id: ABCDEFG
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
|
||||
http_id
|
||||
*Required
|
||||
The value can be obtained by logging in to the Tomato admin interface and
|
||||
search for http_id in the page source code.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
CONF_HTTP_ID = "http_id"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a Tomato scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME,
|
||||
CONF_PASSWORD, CONF_HTTP_ID]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
return TomatoDeviceScanner(config[DOMAIN])
|
||||
|
||||
|
||||
class TomatoDeviceScanner(object):
|
||||
""" This class queries a wireless router running Tomato firmware
|
||||
for connected devices.
|
||||
|
||||
A description of the Tomato API can be found on
|
||||
http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
host, http_id = config[CONF_HOST], config[CONF_HTTP_ID]
|
||||
username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
|
||||
|
||||
self.req = requests.Request('POST',
|
||||
'http://{}/update.cgi'.format(host),
|
||||
data={'_http_id': http_id,
|
||||
'exec': 'devlist'},
|
||||
auth=requests.auth.HTTPBasicAuth(
|
||||
username, password)).prepare()
|
||||
|
||||
self.parse_api_pattern = re.compile(r"(?P<param>\w*) = (?P<value>.*);")
|
||||
|
||||
self.logger = logging.getLogger("{}.{}".format(__name__, "Tomato"))
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.last_results = {"wldev": [], "dhcpd_lease": []}
|
||||
|
||||
self.success_init = self._update_tomato_info()
|
||||
|
||||
def scan_devices(self):
|
||||
""" Scans for new devices and return a
|
||||
list containing found device ids. """
|
||||
|
||||
self._update_tomato_info()
|
||||
|
||||
return [item[1] for item in self.last_results['wldev']]
|
||||
|
||||
def get_device_name(self, device):
|
||||
""" Returns the name of the given device or None if we don't know. """
|
||||
|
||||
filter_named = [item[0] for item in self.last_results['dhcpd_lease']
|
||||
if item[2] == device]
|
||||
|
||||
if not filter_named or not filter_named[0]:
|
||||
return None
|
||||
else:
|
||||
return filter_named[0]
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_tomato_info(self):
|
||||
""" Ensures the information from the Tomato router is up to date.
|
||||
Returns boolean if scanning successful. """
|
||||
|
||||
with self.lock:
|
||||
self.logger.info("Scanning")
|
||||
|
||||
try:
|
||||
response = requests.Session().send(self.req, timeout=3)
|
||||
|
||||
# Calling and parsing the Tomato api here. We only need the
|
||||
# wldev and dhcpd_lease values. For API description see:
|
||||
# http://paulusschoutsen.nl/
|
||||
# blog/2013/10/tomato-api-documentation/
|
||||
if response.status_code == 200:
|
||||
|
||||
for param, value in \
|
||||
self.parse_api_pattern.findall(response.text):
|
||||
|
||||
if param == 'wldev' or param == 'dhcpd_lease':
|
||||
self.last_results[param] = \
|
||||
json.loads(value.replace("'", '"'))
|
||||
|
||||
return True
|
||||
|
||||
elif response.status_code == 401:
|
||||
# Authentication error
|
||||
self.logger.exception((
|
||||
"Failed to authenticate, "
|
||||
"please check your username and password"))
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
# We get this if we could not connect to the router or
|
||||
# an invalid http_id was supplied
|
||||
self.logger.exception((
|
||||
"Failed to connect to the router"
|
||||
" or invalid http_id supplied"))
|
||||
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
# We get this if we could not connect to the router or
|
||||
# an invalid http_id was supplied
|
||||
self.logger.exception(
|
||||
"Connection to the router timed out")
|
||||
|
||||
return False
|
||||
|
||||
except ValueError:
|
||||
# If json decoder could not parse the response
|
||||
self.logger.exception(
|
||||
"Failed to parse response from router")
|
||||
|
||||
return False
|
||||
189
homeassistant/components/device_tracker/tplink.py
Executable file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
homeassistant.components.device_tracker.tplink
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Device tracker platform that supports scanning a TP-Link router for device
|
||||
presence.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the TP-Link tracker you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
device_tracker:
|
||||
platform: tplink
|
||||
host: YOUR_ROUTER_IP
|
||||
username: YOUR_ADMIN_USERNAME
|
||||
password: YOUR_ADMIN_PASSWORD
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The IP address of your router, e.g. 192.168.1.1.
|
||||
|
||||
username
|
||||
*Required
|
||||
The username of an user with administrative privileges, usually 'admin'.
|
||||
|
||||
password
|
||||
*Required
|
||||
The password for your given admin account.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import re
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
""" Validates config and returns a TP-Link scanner. """
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
scanner = Tplink2DeviceScanner(config[DOMAIN])
|
||||
|
||||
if not scanner.success_init:
|
||||
scanner = TplinkDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
class TplinkDeviceScanner(object):
|
||||
"""
|
||||
This class queries a wireless router running TP-Link firmware
|
||||
for connected devices.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
host = config[CONF_HOST]
|
||||
username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
|
||||
|
||||
self.parse_macs = re.compile('[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-' +
|
||||
'[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}')
|
||||
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
self.last_results = {}
|
||||
self.lock = threading.Lock()
|
||||
self.success_init = self._update_info()
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
|
||||
return self.last_results
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def get_device_name(self, device):
|
||||
"""
|
||||
The TP-Link firmware doesn't save the name of the wireless device.
|
||||
"""
|
||||
|
||||
return None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the TP-Link router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Loading wireless clients...")
|
||||
|
||||
url = 'http://{}/userRpm/WlanStationRpm.htm'.format(self.host)
|
||||
referer = 'http://{}'.format(self.host)
|
||||
page = requests.get(url, auth=(self.username, self.password),
|
||||
headers={'referer': referer})
|
||||
|
||||
result = self.parse_macs.findall(page.text)
|
||||
|
||||
if result:
|
||||
self.last_results = [mac.replace("-", ":") for mac in result]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Tplink2DeviceScanner(TplinkDeviceScanner):
|
||||
"""
|
||||
This class queries a wireless router running newer version of TP-Link
|
||||
firmware for connected devices.
|
||||
"""
|
||||
|
||||
def scan_devices(self):
|
||||
"""
|
||||
Scans for new devices and return a list containing found device ids.
|
||||
"""
|
||||
|
||||
self._update_info()
|
||||
return self.last_results.keys()
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def get_device_name(self, device):
|
||||
"""
|
||||
The TP-Link firmware doesn't save the name of the wireless device.
|
||||
"""
|
||||
|
||||
return self.last_results.get(device)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""
|
||||
Ensures the information from the TP-Link router is up to date.
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
|
||||
with self.lock:
|
||||
_LOGGER.info("Loading wireless clients...")
|
||||
|
||||
url = 'http://{}/data/map_access_wireless_client_grid.json'\
|
||||
.format(self.host)
|
||||
referer = 'http://{}'.format(self.host)
|
||||
|
||||
# Router uses Authorization cookie instead of header
|
||||
# Let's create the cookie
|
||||
username_password = '{}:{}'.format(self.username, self.password)
|
||||
b64_encoded_username_password = base64.b64encode(
|
||||
username_password.encode('ascii')
|
||||
).decode('ascii')
|
||||
cookie = 'Authorization=Basic {}'\
|
||||
.format(b64_encoded_username_password)
|
||||
|
||||
response = requests.post(url, headers={'referer': referer,
|
||||
'cookie': cookie})
|
||||
|
||||
try:
|
||||
result = response.json().get('data')
|
||||
except ValueError:
|
||||
_LOGGER.error("Router didn't respond with JSON. "
|
||||
"Check if credentials are correct.")
|
||||
return False
|
||||
|
||||
if result:
|
||||
self.last_results = {
|
||||
device['mac_addr'].replace('-', ':'): device['name']
|
||||
for device in result
|
||||
}
|
||||
return True
|
||||
|
||||
return False
|
||||
106
homeassistant/components/discovery.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
homeassistant.components.discovery
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Starts a service to scan in intervals for new devices.
|
||||
|
||||
Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered.
|
||||
|
||||
Knows which components handle certain types, will make sure they are
|
||||
loaded before the EVENT_PLATFORM_DISCOVERED is fired.
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from homeassistant import bootstrap
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, EVENT_PLATFORM_DISCOVERED,
|
||||
ATTR_SERVICE, ATTR_DISCOVERED)
|
||||
|
||||
DOMAIN = "discovery"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['netdisco==0.3']
|
||||
|
||||
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_HANDLERS = {
|
||||
SERVICE_WEMO: "switch",
|
||||
SERVICE_CAST: "media_player",
|
||||
SERVICE_HUE: "light",
|
||||
SERVICE_NETGEAR: 'device_tracker',
|
||||
}
|
||||
|
||||
|
||||
def listen(hass, service, callback):
|
||||
"""
|
||||
Setup listener for discovery of specific service.
|
||||
Service can be a string or a list/tuple.
|
||||
"""
|
||||
|
||||
if isinstance(service, str):
|
||||
service = (service,)
|
||||
else:
|
||||
service = tuple(service)
|
||||
|
||||
def discovery_event_listener(event):
|
||||
""" Listens for discovery events. """
|
||||
if event.data[ATTR_SERVICE] in service:
|
||||
callback(event.data[ATTR_SERVICE], event.data[ATTR_DISCOVERED])
|
||||
|
||||
hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Starts a discovery service. """
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from netdisco.service import DiscoveryService
|
||||
|
||||
# Disable zeroconf logging, it spams
|
||||
logging.getLogger('zeroconf').setLevel(logging.CRITICAL)
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
def new_service_listener(service, info):
|
||||
""" Called when a new service is found. """
|
||||
with lock:
|
||||
logger.info("Found new service: %s %s", service, info)
|
||||
|
||||
component = SERVICE_HANDLERS.get(service)
|
||||
|
||||
# We do not know how to handle this service
|
||||
if not component:
|
||||
return
|
||||
|
||||
# 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
|
||||
|
||||
hass.bus.fire(EVENT_PLATFORM_DISCOVERED, {
|
||||
ATTR_SERVICE: service,
|
||||
ATTR_DISCOVERED: info
|
||||
})
|
||||
|
||||
def start_discovery(event):
|
||||
""" Start discovering. """
|
||||
netdisco = DiscoveryService(SCAN_INTERVAL)
|
||||
netdisco.add_listener(new_service_listener)
|
||||
netdisco.start()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_discovery)
|
||||
|
||||
return True
|
||||
@@ -9,18 +9,22 @@ import logging
|
||||
import re
|
||||
import threading
|
||||
|
||||
import homeassistant.util as util
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.util import sanitize_filename
|
||||
|
||||
DOMAIN = "downloader"
|
||||
DEPENDENCIES = []
|
||||
|
||||
SERVICE_DOWNLOAD_FILE = "download_file"
|
||||
|
||||
ATTR_URL = "url"
|
||||
ATTR_SUBDIR = "subdir"
|
||||
|
||||
CONF_DOWNLOAD_DIR = 'download_dir'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def setup(bus, download_path):
|
||||
def setup(hass, config):
|
||||
""" Listens for download events to download files. """
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,18 +37,23 @@ def setup(bus, download_path):
|
||||
|
||||
return False
|
||||
|
||||
if not validate_config(config, {DOMAIN: [CONF_DOWNLOAD_DIR]}, logger):
|
||||
return False
|
||||
|
||||
download_path = config[DOMAIN][CONF_DOWNLOAD_DIR]
|
||||
|
||||
if not os.path.isdir(download_path):
|
||||
|
||||
logger.error(
|
||||
("Download path {} does not exist. File Downloader not active.").
|
||||
format(download_path))
|
||||
"Download path %s does not exist. File Downloader not active.",
|
||||
download_path)
|
||||
|
||||
return False
|
||||
|
||||
def download_file(service):
|
||||
""" Starts thread to download file specified in the url. """
|
||||
|
||||
if not ATTR_URL in service.data:
|
||||
if ATTR_URL not in service.data:
|
||||
logger.error("Service called but 'url' parameter not specified.")
|
||||
return
|
||||
|
||||
@@ -56,11 +65,11 @@ def setup(bus, download_path):
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
if subdir:
|
||||
subdir = util.sanitize_filename(subdir)
|
||||
subdir = sanitize_filename(subdir)
|
||||
|
||||
final_path = None
|
||||
|
||||
req = requests.get(url, stream=True)
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code == 200:
|
||||
filename = None
|
||||
@@ -80,7 +89,7 @@ def setup(bus, download_path):
|
||||
filename = "ha_download"
|
||||
|
||||
# Remove stuff to ruin paths
|
||||
filename = util.sanitize_filename(filename)
|
||||
filename = sanitize_filename(filename)
|
||||
|
||||
# Do we want to download to subdir, create if needed
|
||||
if subdir:
|
||||
@@ -106,19 +115,16 @@ def setup(bus, download_path):
|
||||
|
||||
final_path = "{}_{}.{}".format(path, tries, ext)
|
||||
|
||||
logger.info("{} -> {}".format(
|
||||
url, final_path))
|
||||
logger.info("%s -> %s", url, final_path)
|
||||
|
||||
with open(final_path, 'wb') as fil:
|
||||
for chunk in req.iter_content(1024):
|
||||
fil.write(chunk)
|
||||
|
||||
logger.info("Downloading of {} done".format(
|
||||
url))
|
||||
logger.info("Downloading of %s done", url)
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.exception("ConnectionError occured for {}".
|
||||
format(url))
|
||||
logger.exception("ConnectionError occured for %s", url)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
@@ -126,7 +132,7 @@ def setup(bus, download_path):
|
||||
|
||||
threading.Thread(target=do_download).start()
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_DOWNLOAD_FILE,
|
||||
download_file)
|
||||
hass.services.register(DOMAIN, SERVICE_DOWNLOAD_FILE,
|
||||
download_file)
|
||||
|
||||
return True
|
||||
|
||||
85
homeassistant/components/frontend/__init__.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
homeassistant.components.frontend
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides a frontend for Home Assistant.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
|
||||
from . import version
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import URL_ROOT, HTTP_OK
|
||||
|
||||
DOMAIN = 'frontend'
|
||||
DEPENDENCIES = ['api']
|
||||
|
||||
INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template')
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FRONTEND_URLS = [
|
||||
URL_ROOT, '/logbook', '/history', '/devService', '/devState', '/devEvent']
|
||||
STATES_URL = re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)')
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup serving the frontend. """
|
||||
if 'http' not in hass.config.components:
|
||||
_LOGGER.error('Dependency http is not loaded')
|
||||
return False
|
||||
|
||||
for url in FRONTEND_URLS:
|
||||
hass.http.register_path('GET', url, _handle_get_root, False)
|
||||
|
||||
hass.http.register_path('GET', STATES_URL, _handle_get_root, False)
|
||||
|
||||
# Static files
|
||||
hass.http.register_path(
|
||||
'GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_static, False)
|
||||
hass.http.register_path(
|
||||
'HEAD', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_static, False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_root(handler, path_match, data):
|
||||
""" Renders the debug interface. """
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
handler.end_headers()
|
||||
|
||||
if handler.server.development:
|
||||
app_url = "home-assistant-polymer/src/home-assistant.html"
|
||||
else:
|
||||
app_url = "frontend-{}.html".format(version.VERSION)
|
||||
|
||||
# auto login if no password was set, else check api_password param
|
||||
auth = ('no_password_set' if handler.server.no_password_set
|
||||
else data.get('api_password', ''))
|
||||
|
||||
with open(INDEX_PATH) as template_file:
|
||||
template_html = template_file.read()
|
||||
|
||||
template_html = template_html.replace('{{ app_url }}', app_url)
|
||||
template_html = template_html.replace('{{ auth }}', auth)
|
||||
|
||||
handler.wfile.write(template_html.encode("UTF-8"))
|
||||
|
||||
|
||||
def _handle_get_static(handler, path_match, data):
|
||||
""" Returns a static file for the frontend. """
|
||||
req_file = util.sanitize_path(path_match.group('file'))
|
||||
|
||||
# Strip md5 hash out of frontend filename
|
||||
if re.match(r'^frontend-[A-Za-z0-9]{32}\.html$', req_file):
|
||||
req_file = "frontend.html"
|
||||
|
||||
path = os.path.join(os.path.dirname(__file__), 'www_static', req_file)
|
||||
|
||||
handler.write_file(path)
|
||||
51
homeassistant/components/frontend/index.html.template
Normal file
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Home Assistant</title>
|
||||
|
||||
<link rel='manifest' href='/static/manifest.json' />
|
||||
<link rel='shortcut icon' href='/static/favicon.ico' />
|
||||
<link rel='icon' type='image/png'
|
||||
href='/static/favicon-192x192.png' sizes='192x192'>
|
||||
<link rel='apple-touch-icon' sizes='180x180'
|
||||
href='/static/favicon-apple-180x180.png'>
|
||||
<meta name='apple-mobile-web-app-capable' content='yes'>
|
||||
<meta name='mobile-web-app-capable' content='yes'>
|
||||
<meta name='viewport' content='width=device-width,
|
||||
user-scalable=no' />
|
||||
<meta name='theme-color' content='#03a9f4'>
|
||||
<style>
|
||||
#init {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-webkit-justify-content: center;
|
||||
-webkit-align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-family: 'Roboto', 'Noto', sans-serif;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
#init div {
|
||||
line-height: 34px;
|
||||
margin-bottom: 89px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body fullbleed>
|
||||
<div id='init'>
|
||||
<img src='/static/splash.png' height='230' />
|
||||
<div>Initializing</div>
|
||||
</div>
|
||||
<script src='/static/webcomponents-lite.min.js'></script>
|
||||
<link rel='import' href='/static/{{ app_url }}' />
|
||||
<home-assistant auth='{{ auth }}'></home-assistant>
|
||||
</body>
|
||||
</html>
|
||||
2
homeassistant/components/frontend/version.py
Normal file
@@ -0,0 +1,2 @@
|
||||
""" DO NOT MODIFY. Auto-generated by build_frontend script """
|
||||
VERSION = "35ecb5457a9ff0f4142c2605b53eb843"
|
||||
BIN
homeassistant/components/frontend/www_static/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
homeassistant/components/frontend/www_static/favicon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
5092
homeassistant/components/frontend/www_static/frontend.html
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
14
homeassistant/components/frontend/www_static/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Home Assistant",
|
||||
"short_name": "Assistant",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/static\/favicon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
homeassistant/components/frontend/www_static/splash.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
12
homeassistant/components/frontend/www_static/webcomponents-lite.min.js
vendored
Normal file
@@ -1,59 +1,63 @@
|
||||
"""
|
||||
homeassistant.components.groups
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
homeassistant.components.group
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to group devices that can be turned on or off.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.helpers import generate_entity_id
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.helpers.entity import Entity
|
||||
import homeassistant.util as util
|
||||
from homeassistant.components import (STATE_ON, STATE_OFF,
|
||||
STATE_HOME, STATE_NOT_HOME,
|
||||
ATTR_ENTITY_ID)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, STATE_ON, STATE_OFF,
|
||||
STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN)
|
||||
|
||||
DOMAIN = "group"
|
||||
DEPENDENCIES = []
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
_GROUP_TYPES = {
|
||||
"on_off": (STATE_ON, STATE_OFF),
|
||||
"home_not_home": (STATE_HOME, STATE_NOT_HOME)
|
||||
}
|
||||
ATTR_AUTO = "auto"
|
||||
|
||||
# List of ON/OFF state tuples for groupable states
|
||||
_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME)]
|
||||
|
||||
|
||||
def _get_group_type(state):
|
||||
""" Determine the group type based on the given group type. """
|
||||
for group_type, states in _GROUP_TYPES.items():
|
||||
def _get_group_on_off(state):
|
||||
""" Determine the group on/off states based on a state. """
|
||||
for states in _GROUP_TYPES:
|
||||
if state in states:
|
||||
return group_type
|
||||
return states
|
||||
|
||||
return None
|
||||
return None, None
|
||||
|
||||
|
||||
def is_on(statemachine, entity_id):
|
||||
def is_on(hass, entity_id):
|
||||
""" Returns if the group state is in its ON-state. """
|
||||
state = statemachine.get_state(entity_id)
|
||||
state = hass.states.get(entity_id)
|
||||
|
||||
if state:
|
||||
group_type = _get_group_type(state.state)
|
||||
group_on, _ = _get_group_on_off(state.state)
|
||||
|
||||
if group_type:
|
||||
# We found group_type, compare to ON-state
|
||||
return state.state == _GROUP_TYPES[group_type][0]
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
# If we found a group_type, compare to ON-state
|
||||
return group_on is not None and state.state == group_on
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def expand_entity_ids(statemachine, entity_ids):
|
||||
def expand_entity_ids(hass, entity_ids):
|
||||
""" Returns the given list of entity ids and expands group ids into
|
||||
the entity ids it represents if found. """
|
||||
found_ids = []
|
||||
|
||||
for entity_id in entity_ids:
|
||||
if not isinstance(entity_id, str):
|
||||
continue
|
||||
|
||||
entity_id = entity_id.lower()
|
||||
|
||||
try:
|
||||
# If entity_id points at a group, expand it
|
||||
domain, _ = util.split_entity_id(entity_id)
|
||||
@@ -61,7 +65,7 @@ def expand_entity_ids(statemachine, entity_ids):
|
||||
if domain == DOMAIN:
|
||||
found_ids.extend(
|
||||
ent_id for ent_id
|
||||
in get_entity_ids(statemachine, entity_id)
|
||||
in get_entity_ids(hass, entity_id)
|
||||
if ent_id not in found_ids)
|
||||
|
||||
else:
|
||||
@@ -75,97 +79,149 @@ def expand_entity_ids(statemachine, entity_ids):
|
||||
return found_ids
|
||||
|
||||
|
||||
def get_entity_ids(statemachine, entity_id):
|
||||
def get_entity_ids(hass, entity_id, domain_filter=None):
|
||||
""" Get the entity ids that make up this group. """
|
||||
entity_id = entity_id.lower()
|
||||
|
||||
try:
|
||||
return \
|
||||
statemachine.get_state(entity_id).attributes[ATTR_ENTITY_ID]
|
||||
entity_ids = hass.states.get(entity_id).attributes[ATTR_ENTITY_ID]
|
||||
|
||||
if domain_filter:
|
||||
domain_filter = domain_filter.lower()
|
||||
|
||||
return [ent_id for ent_id in entity_ids
|
||||
if ent_id.startswith(domain_filter)]
|
||||
else:
|
||||
return entity_ids
|
||||
|
||||
except (AttributeError, KeyError):
|
||||
# AttributeError if state did not exist
|
||||
# KeyError if key did not exist in attributes
|
||||
return []
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-locals
|
||||
def setup(bus, statemachine, name, entity_ids):
|
||||
def setup(hass, config):
|
||||
""" Sets up all groups found definded in the configuration. """
|
||||
for name, entity_ids in config.get(DOMAIN, {}).items():
|
||||
if isinstance(entity_ids, str):
|
||||
entity_ids = [ent.strip() for ent in entity_ids.split(",")]
|
||||
setup_group(hass, name, entity_ids)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Group(Entity):
|
||||
""" Tracks a group of entity ids. """
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
def __init__(self, hass, name, entity_ids=None, user_defined=True):
|
||||
self.hass = hass
|
||||
self._name = name
|
||||
self._state = STATE_UNKNOWN
|
||||
self.user_defined = user_defined
|
||||
self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass)
|
||||
self.tracking = []
|
||||
self.group_on = None
|
||||
self.group_off = None
|
||||
|
||||
if entity_ids is not None:
|
||||
self.update_tracked_entity_ids(entity_ids)
|
||||
else:
|
||||
self.update_ha_state(True)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
return {
|
||||
ATTR_ENTITY_ID: self.tracking,
|
||||
ATTR_AUTO: not self.user_defined,
|
||||
}
|
||||
|
||||
def update_tracked_entity_ids(self, entity_ids):
|
||||
""" Update the tracked entity IDs. """
|
||||
self.stop()
|
||||
self.tracking = tuple(ent_id.lower() for ent_id in entity_ids)
|
||||
self.group_on, self.group_off = None, None
|
||||
|
||||
self.update_ha_state(True)
|
||||
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
""" Starts the tracking. """
|
||||
track_state_change(
|
||||
self.hass, self.tracking, self._state_changed_listener)
|
||||
|
||||
def stop(self):
|
||||
""" Unregisters the group from Home Assistant. """
|
||||
self.hass.states.remove(self.entity_id)
|
||||
|
||||
self.hass.bus.remove_listener(
|
||||
ha.EVENT_STATE_CHANGED, self._state_changed_listener)
|
||||
|
||||
def update(self):
|
||||
""" Query all the tracked states and determine current group state. """
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
for entity_id in self.tracking:
|
||||
state = self.hass.states.get(entity_id)
|
||||
|
||||
if state is not None:
|
||||
self._process_tracked_state(state)
|
||||
|
||||
def _state_changed_listener(self, entity_id, old_state, new_state):
|
||||
""" Listener to receive state changes of tracked entities. """
|
||||
self._process_tracked_state(new_state)
|
||||
self.update_ha_state()
|
||||
|
||||
def _process_tracked_state(self, tr_state):
|
||||
""" Updates group state based on a new state of a tracked entity. """
|
||||
|
||||
# We have not determined type of group yet
|
||||
if self.group_on is None:
|
||||
self.group_on, self.group_off = _get_group_on_off(tr_state.state)
|
||||
|
||||
if self.group_on is not None:
|
||||
# New state of the group is going to be based on the first
|
||||
# state that we can recognize
|
||||
self._state = tr_state.state
|
||||
|
||||
return
|
||||
|
||||
# There is already a group state
|
||||
cur_gr_state = self._state
|
||||
group_on, group_off = self.group_on, self.group_off
|
||||
|
||||
# if cur_gr_state = OFF and tr_state = ON: set ON
|
||||
# if cur_gr_state = ON and tr_state = OFF: research
|
||||
# else: ignore
|
||||
|
||||
if cur_gr_state == group_off and tr_state.state == group_on:
|
||||
self._state = group_on
|
||||
|
||||
elif cur_gr_state == group_on and tr_state.state == group_off:
|
||||
|
||||
# Set to off if no other states are on
|
||||
if not any(self.hass.states.is_state(ent_id, group_on)
|
||||
for ent_id in self.tracking
|
||||
if tr_state.entity_id != ent_id):
|
||||
self._state = group_off
|
||||
|
||||
|
||||
def setup_group(hass, name, entity_ids, user_defined=True):
|
||||
""" Sets up a group state that is the combined state of
|
||||
several states. Supports ON/OFF and DEVICE_HOME/DEVICE_NOT_HOME. """
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Loop over the given entities to:
|
||||
# - determine which group type this is (on_off, device_home)
|
||||
# - if all states exist and have valid states
|
||||
# - retrieve the current state of the group
|
||||
errors = []
|
||||
group_type, group_on, group_off, group_state = None, None, None, None
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = statemachine.get_state(entity_id)
|
||||
|
||||
# Try to determine group type if we didn't yet
|
||||
if not group_type and state:
|
||||
group_type = _get_group_type(state.state)
|
||||
|
||||
if group_type:
|
||||
group_on, group_off = _GROUP_TYPES[group_type]
|
||||
group_state = group_off
|
||||
|
||||
else:
|
||||
# We did not find a matching group_type
|
||||
errors.append("Found unexpected state '{}'".format(
|
||||
name, state.state))
|
||||
|
||||
break
|
||||
|
||||
# Check if entity exists
|
||||
if not state:
|
||||
errors.append("Entity {} does not exist".format(entity_id))
|
||||
|
||||
# Check if entity is valid state
|
||||
elif state.state != group_off and state.state != group_on:
|
||||
|
||||
errors.append("State of {} is {} (expected: {}, {})".format(
|
||||
entity_id, state.state, group_off, group_on))
|
||||
|
||||
# Keep track of the group state to init later on
|
||||
elif group_state == group_off and state.state == group_on:
|
||||
group_state = group_on
|
||||
|
||||
if errors:
|
||||
logger.error("Error setting up state group {}: {}".format(
|
||||
name, ", ".join(errors)))
|
||||
|
||||
return False
|
||||
|
||||
group_entity_id = ENTITY_ID_FORMAT.format(name)
|
||||
state_attr = {ATTR_ENTITY_ID: entity_ids}
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def update_group_state(entity_id, old_state, new_state):
|
||||
""" Updates the group state based on a state change by a tracked
|
||||
entity. """
|
||||
|
||||
cur_group_state = statemachine.get_state(group_entity_id).state
|
||||
|
||||
# if cur_group_state = OFF and new_state = ON: set ON
|
||||
# if cur_group_state = ON and new_state = OFF: research
|
||||
# else: ignore
|
||||
|
||||
if cur_group_state == group_off and new_state.state == group_on:
|
||||
|
||||
statemachine.set_state(group_entity_id, group_on, state_attr)
|
||||
|
||||
elif cur_group_state == group_on and new_state.state == group_off:
|
||||
|
||||
# Check if any of the other states is still on
|
||||
if not any([statemachine.is_state(ent_id, group_on)
|
||||
for ent_id in entity_ids if entity_id != ent_id]):
|
||||
statemachine.set_state(group_entity_id, group_off, state_attr)
|
||||
|
||||
for entity_id in entity_ids:
|
||||
ha.track_state_change(bus, entity_id, update_group_state)
|
||||
|
||||
statemachine.set_state(group_entity_id, group_state, state_attr)
|
||||
|
||||
return True
|
||||
return Group(hass, name, entity_ids, user_defined)
|
||||
|
||||
155
homeassistant/components/history.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
homeassistant.components.history
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provide pre-made queries on top of the recorder component.
|
||||
"""
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from itertools import groupby
|
||||
from collections import defaultdict
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
import homeassistant.components.recorder as recorder
|
||||
from homeassistant.const import HTTP_BAD_REQUEST
|
||||
|
||||
DOMAIN = 'history'
|
||||
DEPENDENCIES = ['recorder', 'http']
|
||||
|
||||
URL_HISTORY_PERIOD = re.compile(
|
||||
r'/api/history/period(?:/(?P<date>\d{4}-\d{1,2}-\d{1,2})|)')
|
||||
|
||||
|
||||
def last_5_states(entity_id):
|
||||
""" Return the last 5 states for entity_id. """
|
||||
entity_id = entity_id.lower()
|
||||
|
||||
query = """
|
||||
SELECT * FROM states WHERE entity_id=? AND
|
||||
last_changed=last_updated
|
||||
ORDER BY state_id DESC LIMIT 0, 5
|
||||
"""
|
||||
|
||||
return recorder.query_states(query, (entity_id, ))
|
||||
|
||||
|
||||
def state_changes_during_period(start_time, end_time=None, entity_id=None):
|
||||
"""
|
||||
Return states changes during UTC period start_time - end_time.
|
||||
"""
|
||||
where = "last_changed=last_updated AND last_changed > ? "
|
||||
data = [start_time]
|
||||
|
||||
if end_time is not None:
|
||||
where += "AND last_changed < ? "
|
||||
data.append(end_time)
|
||||
|
||||
if entity_id is not None:
|
||||
where += "AND entity_id = ? "
|
||||
data.append(entity_id.lower())
|
||||
|
||||
query = ("SELECT * FROM states WHERE {} "
|
||||
"ORDER BY entity_id, last_changed ASC").format(where)
|
||||
|
||||
states = recorder.query_states(query, data)
|
||||
|
||||
result = defaultdict(list)
|
||||
|
||||
entity_ids = [entity_id] if entity_id is not None else None
|
||||
|
||||
# Get the states at the start time
|
||||
for state in get_states(start_time, entity_ids):
|
||||
state.last_changed = start_time
|
||||
result[state.entity_id].append(state)
|
||||
|
||||
# Append all changes to it
|
||||
for entity_id, group in groupby(states, lambda state: state.entity_id):
|
||||
result[entity_id].extend(group)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_states(utc_point_in_time, entity_ids=None, run=None):
|
||||
""" Returns the states at a specific point in time. """
|
||||
if run is None:
|
||||
run = recorder.run_information(utc_point_in_time)
|
||||
|
||||
# History did not run before utc_point_in_time
|
||||
if run is None:
|
||||
return []
|
||||
|
||||
where = run.where_after_start_run + "AND created < ? "
|
||||
where_data = [utc_point_in_time]
|
||||
|
||||
if entity_ids is not None:
|
||||
where += "AND entity_id IN ({}) ".format(
|
||||
",".join(['?'] * len(entity_ids)))
|
||||
where_data.extend(entity_ids)
|
||||
|
||||
query = """
|
||||
SELECT * FROM states
|
||||
INNER JOIN (
|
||||
SELECT max(state_id) AS max_state_id
|
||||
FROM states WHERE {}
|
||||
GROUP BY entity_id)
|
||||
WHERE state_id = max_state_id
|
||||
""".format(where)
|
||||
|
||||
return recorder.query_states(query, where_data)
|
||||
|
||||
|
||||
def get_state(utc_point_in_time, entity_id, run=None):
|
||||
""" Return a state at a specific point in time. """
|
||||
states = get_states(utc_point_in_time, (entity_id,), run)
|
||||
|
||||
return states[0] if states else None
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup(hass, config):
|
||||
""" Setup history hooks. """
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(
|
||||
r'/api/history/entity/(?P<entity_id>[a-zA-Z\._0-9]+)/'
|
||||
r'recent_states'),
|
||||
_api_last_5_states)
|
||||
|
||||
hass.http.register_path('GET', URL_HISTORY_PERIOD, _api_history_period)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=invalid-name
|
||||
def _api_last_5_states(handler, path_match, data):
|
||||
""" Return the last 5 states for an entity id as JSON. """
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
handler.write_json(last_5_states(entity_id))
|
||||
|
||||
|
||||
def _api_history_period(handler, path_match, data):
|
||||
""" Return history over a period of time. """
|
||||
date_str = path_match.group('date')
|
||||
one_day = timedelta(seconds=86400)
|
||||
|
||||
if date_str:
|
||||
start_date = dt_util.date_str_to_date(date_str)
|
||||
|
||||
if start_date is None:
|
||||
handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
start_time = dt_util.as_utc(dt_util.start_of_local_day(start_date))
|
||||
else:
|
||||
start_time = dt_util.utcnow() - one_day
|
||||
|
||||
end_time = start_time + one_day
|
||||
|
||||
print("Fetchign", start_time, end_time)
|
||||
|
||||
entity_id = data.get('filter_entity_id')
|
||||
|
||||
handler.write_json(
|
||||
state_changes_during_period(start_time, end_time, entity_id).values())
|
||||
538
homeassistant/components/http.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
homeassistant.components.httpinterface
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module provides an API and a HTTP interface for debug purposes.
|
||||
|
||||
By default it will run on port 8123.
|
||||
|
||||
All API calls have to be accompanied by an 'api_password' parameter and will
|
||||
return JSON. If successful calls will return status code 200 or 201.
|
||||
|
||||
Other status codes that can occur are:
|
||||
- 400 (Bad Request)
|
||||
- 401 (Unauthorized)
|
||||
- 404 (Not Found)
|
||||
- 405 (Method not allowed)
|
||||
|
||||
The api supports the following actions:
|
||||
|
||||
/api - GET
|
||||
Returns message if API is up and running.
|
||||
Example result:
|
||||
{
|
||||
"message": "API running."
|
||||
}
|
||||
|
||||
/api/states - GET
|
||||
Returns a list of entities for which a state is available
|
||||
Example result:
|
||||
[
|
||||
{ .. state object .. },
|
||||
{ .. state object .. }
|
||||
]
|
||||
|
||||
/api/states/<entity_id> - GET
|
||||
Returns the current state from an entity
|
||||
Example result:
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
|
||||
/api/states/<entity_id> - POST
|
||||
Updates the current state of an entity. Returns status code 201 if successful
|
||||
with location header of updated resource and as body the new state.
|
||||
parameter: new_state - string
|
||||
optional parameter: attributes - JSON encoded object
|
||||
Example result:
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
|
||||
/api/events/<event_type> - POST
|
||||
Fires an event with event_type
|
||||
optional parameter: event_data - JSON encoded object
|
||||
Example result:
|
||||
{
|
||||
"message": "Event download_file fired."
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
import gzip
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from datetime import timedelta
|
||||
from homeassistant.util import Throttle
|
||||
from http.server import SimpleHTTPRequestHandler, HTTPServer
|
||||
from http import cookies
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.const import (
|
||||
SERVER_PORT, CONTENT_TYPE_JSON,
|
||||
HTTP_HEADER_HA_AUTH, HTTP_HEADER_CONTENT_TYPE, HTTP_HEADER_ACCEPT_ENCODING,
|
||||
HTTP_HEADER_CONTENT_ENCODING, HTTP_HEADER_VARY, HTTP_HEADER_CONTENT_LENGTH,
|
||||
HTTP_HEADER_CACHE_CONTROL, HTTP_HEADER_EXPIRES, HTTP_OK, HTTP_UNAUTHORIZED,
|
||||
HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED, HTTP_UNPROCESSABLE_ENTITY)
|
||||
import homeassistant.remote as rem
|
||||
import homeassistant.util as util
|
||||
import homeassistant.util.dt as date_util
|
||||
import homeassistant.bootstrap as bootstrap
|
||||
|
||||
DOMAIN = "http"
|
||||
DEPENDENCIES = []
|
||||
|
||||
CONF_API_PASSWORD = "api_password"
|
||||
CONF_SERVER_HOST = "server_host"
|
||||
CONF_SERVER_PORT = "server_port"
|
||||
CONF_DEVELOPMENT = "development"
|
||||
CONF_SESSIONS_ENABLED = "sessions_enabled"
|
||||
|
||||
DATA_API_PASSWORD = 'api_password'
|
||||
|
||||
# Throttling time in seconds for expired sessions check
|
||||
MIN_SEC_SESSION_CLEARING = timedelta(seconds=20)
|
||||
SESSION_TIMEOUT_SECONDS = 1800
|
||||
SESSION_KEY = 'sessionId'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config=None):
|
||||
""" Sets up the HTTP API and debug interface. """
|
||||
if config is None or DOMAIN not in config:
|
||||
config = {DOMAIN: {}}
|
||||
|
||||
api_password = util.convert(config[DOMAIN].get(CONF_API_PASSWORD), str)
|
||||
|
||||
no_password_set = api_password is None
|
||||
|
||||
if no_password_set:
|
||||
api_password = util.get_random_string()
|
||||
|
||||
# If no server host is given, accept all incoming requests
|
||||
server_host = config[DOMAIN].get(CONF_SERVER_HOST, '0.0.0.0')
|
||||
|
||||
server_port = config[DOMAIN].get(CONF_SERVER_PORT, SERVER_PORT)
|
||||
|
||||
development = str(config[DOMAIN].get(CONF_DEVELOPMENT, "")) == "1"
|
||||
|
||||
sessions_enabled = config[DOMAIN].get(CONF_SESSIONS_ENABLED, True)
|
||||
|
||||
try:
|
||||
server = HomeAssistantHTTPServer(
|
||||
(server_host, server_port), RequestHandler, hass, api_password,
|
||||
development, no_password_set, sessions_enabled)
|
||||
except OSError:
|
||||
# Happens if address already in use
|
||||
_LOGGER.exception("Error setting up HTTP server")
|
||||
return False
|
||||
|
||||
hass.bus.listen_once(
|
||||
ha.EVENT_HOMEASSISTANT_START,
|
||||
lambda event:
|
||||
threading.Thread(target=server.start, daemon=True).start())
|
||||
|
||||
hass.http = server
|
||||
hass.config.api = rem.API(util.get_local_ip(), api_password, server_port)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
""" Handle HTTP requests in a threaded fashion. """
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, server_address, request_handler_class,
|
||||
hass, api_password, development, no_password_set,
|
||||
sessions_enabled):
|
||||
super().__init__(server_address, request_handler_class)
|
||||
|
||||
self.server_address = server_address
|
||||
self.hass = hass
|
||||
self.api_password = api_password
|
||||
self.development = development
|
||||
self.no_password_set = no_password_set
|
||||
self.paths = []
|
||||
self.sessions = SessionStore(sessions_enabled)
|
||||
|
||||
# We will lazy init this one if needed
|
||||
self.event_forwarder = None
|
||||
|
||||
if development:
|
||||
_LOGGER.info("running http in development mode")
|
||||
|
||||
def start(self):
|
||||
""" Starts the HTTP server. """
|
||||
def stop_http(event):
|
||||
""" Stops the HTTP server. """
|
||||
self.shutdown()
|
||||
|
||||
self.hass.bus.listen_once(ha.EVENT_HOMEASSISTANT_STOP, stop_http)
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting web interface at http://%s:%d", *self.server_address)
|
||||
|
||||
# 31-1-2015: Refactored frontend/api components out of this component
|
||||
# To prevent stuff from breaking, load the two extracted components
|
||||
bootstrap.setup_component(self.hass, 'api')
|
||||
bootstrap.setup_component(self.hass, 'frontend')
|
||||
|
||||
self.serve_forever()
|
||||
|
||||
def register_path(self, method, url, callback, require_auth=True):
|
||||
""" Registers a path wit the server. """
|
||||
self.paths.append((method, url, callback, require_auth))
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
""" Redirect built-in log to HA logging """
|
||||
# pylint: disable=no-self-use
|
||||
_LOGGER.info(fmt, *args)
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods,too-many-locals
|
||||
class RequestHandler(SimpleHTTPRequestHandler):
|
||||
"""
|
||||
Handles incoming HTTP requests
|
||||
|
||||
We extend from SimpleHTTPRequestHandler instead of Base so we
|
||||
can use the guess content type methods.
|
||||
"""
|
||||
|
||||
server_version = "HomeAssistant/1.0"
|
||||
|
||||
def __init__(self, req, client_addr, server):
|
||||
""" Contructor, call the base constructor and set up session """
|
||||
self._session = None
|
||||
SimpleHTTPRequestHandler.__init__(self, req, client_addr, server)
|
||||
|
||||
def log_message(self, fmt, *arguments):
|
||||
""" Redirect built-in log to HA logging """
|
||||
_LOGGER.info(fmt, *arguments)
|
||||
|
||||
def _handle_request(self, method): # pylint: disable=too-many-branches
|
||||
""" Does some common checks and calls appropriate method. """
|
||||
url = urlparse(self.path)
|
||||
|
||||
# Read query input
|
||||
data = parse_qs(url.query)
|
||||
|
||||
# parse_qs gives a list for each value, take the latest element
|
||||
for key in data:
|
||||
data[key] = data[key][-1]
|
||||
|
||||
# Did we get post input ?
|
||||
content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0))
|
||||
|
||||
if content_length:
|
||||
body_content = self.rfile.read(content_length).decode("UTF-8")
|
||||
|
||||
try:
|
||||
data.update(json.loads(body_content))
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if JSON object is not a dict
|
||||
# ValueError if we could not parse JSON
|
||||
_LOGGER.exception(
|
||||
"Exception parsing JSON: %s", body_content)
|
||||
self.write_json_message(
|
||||
"Error parsing JSON", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
|
||||
self._session = self.get_session()
|
||||
if self.server.no_password_set:
|
||||
api_password = self.server.api_password
|
||||
else:
|
||||
api_password = self.headers.get(HTTP_HEADER_HA_AUTH)
|
||||
|
||||
if not api_password and DATA_API_PASSWORD in data:
|
||||
api_password = data[DATA_API_PASSWORD]
|
||||
|
||||
if not api_password and self._session is not None:
|
||||
api_password = self._session.cookie_values.get(
|
||||
CONF_API_PASSWORD)
|
||||
|
||||
if '_METHOD' in data:
|
||||
method = data.pop('_METHOD')
|
||||
|
||||
# Var to keep track if we found a path that matched a handler but
|
||||
# the method was different
|
||||
path_matched_but_not_method = False
|
||||
|
||||
# Var to hold the handler for this path and method if found
|
||||
handle_request_method = False
|
||||
require_auth = True
|
||||
|
||||
# Check every handler to find matching result
|
||||
for t_method, t_path, t_handler, t_auth in self.server.paths:
|
||||
# we either do string-comparison or regular expression matching
|
||||
# pylint: disable=maybe-no-member
|
||||
if isinstance(t_path, str):
|
||||
path_match = url.path == t_path
|
||||
else:
|
||||
path_match = t_path.match(url.path)
|
||||
|
||||
if path_match and method == t_method:
|
||||
# Call the method
|
||||
handle_request_method = t_handler
|
||||
require_auth = t_auth
|
||||
break
|
||||
|
||||
elif path_match:
|
||||
path_matched_but_not_method = True
|
||||
|
||||
# Did we find a handler for the incoming request?
|
||||
if handle_request_method:
|
||||
|
||||
# For some calls we need a valid password
|
||||
if require_auth and api_password != self.server.api_password:
|
||||
self.write_json_message(
|
||||
"API password missing or incorrect.", HTTP_UNAUTHORIZED)
|
||||
|
||||
else:
|
||||
if self._session is None and require_auth:
|
||||
self._session = self.server.sessions.create(
|
||||
api_password)
|
||||
|
||||
handle_request_method(self, path_match, data)
|
||||
|
||||
elif path_matched_but_not_method:
|
||||
self.send_response(HTTP_METHOD_NOT_ALLOWED)
|
||||
self.end_headers()
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
self.end_headers()
|
||||
|
||||
def do_HEAD(self): # pylint: disable=invalid-name
|
||||
""" HEAD request handler. """
|
||||
self._handle_request('HEAD')
|
||||
|
||||
def do_GET(self): # pylint: disable=invalid-name
|
||||
""" GET request handler. """
|
||||
self._handle_request('GET')
|
||||
|
||||
def do_POST(self): # pylint: disable=invalid-name
|
||||
""" POST request handler. """
|
||||
self._handle_request('POST')
|
||||
|
||||
def do_PUT(self): # pylint: disable=invalid-name
|
||||
""" PUT request handler. """
|
||||
self._handle_request('PUT')
|
||||
|
||||
def do_DELETE(self): # pylint: disable=invalid-name
|
||||
""" DELETE request handler. """
|
||||
self._handle_request('DELETE')
|
||||
|
||||
def write_json_message(self, message, status_code=HTTP_OK):
|
||||
""" Helper method to return a message to the caller. """
|
||||
self.write_json({'message': message}, status_code=status_code)
|
||||
|
||||
def write_json(self, data=None, status_code=HTTP_OK, location=None):
|
||||
""" Helper method to return JSON to the caller. """
|
||||
self.send_response(status_code)
|
||||
self.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON)
|
||||
|
||||
if location:
|
||||
self.send_header('Location', location)
|
||||
|
||||
self.set_session_cookie_header()
|
||||
|
||||
self.end_headers()
|
||||
|
||||
if data is not None:
|
||||
self.wfile.write(
|
||||
json.dumps(data, indent=4, sort_keys=True,
|
||||
cls=rem.JSONEncoder).encode("UTF-8"))
|
||||
|
||||
def write_file(self, path):
|
||||
""" Returns a file to the user. """
|
||||
try:
|
||||
with open(path, 'rb') as inp:
|
||||
self.write_file_pointer(self.guess_type(path), inp)
|
||||
|
||||
except IOError:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
self.end_headers()
|
||||
_LOGGER.exception("Unable to serve %s", path)
|
||||
|
||||
def write_file_pointer(self, content_type, inp):
|
||||
"""
|
||||
Helper function to write a file pointer to the user.
|
||||
Does not do error handling.
|
||||
"""
|
||||
do_gzip = 'gzip' in self.headers.get(HTTP_HEADER_ACCEPT_ENCODING, '')
|
||||
|
||||
self.send_response(HTTP_OK)
|
||||
self.send_header(HTTP_HEADER_CONTENT_TYPE, content_type)
|
||||
|
||||
self.set_cache_header()
|
||||
self.set_session_cookie_header()
|
||||
|
||||
if do_gzip:
|
||||
gzip_data = gzip.compress(inp.read())
|
||||
|
||||
self.send_header(HTTP_HEADER_CONTENT_ENCODING, "gzip")
|
||||
self.send_header(HTTP_HEADER_VARY, HTTP_HEADER_ACCEPT_ENCODING)
|
||||
self.send_header(HTTP_HEADER_CONTENT_LENGTH, str(len(gzip_data)))
|
||||
|
||||
else:
|
||||
fst = os.fstat(inp.fileno())
|
||||
self.send_header(HTTP_HEADER_CONTENT_LENGTH, str(fst[6]))
|
||||
|
||||
self.end_headers()
|
||||
|
||||
if self.command == 'HEAD':
|
||||
return
|
||||
|
||||
elif do_gzip:
|
||||
self.wfile.write(gzip_data)
|
||||
|
||||
else:
|
||||
self.copyfile(inp, self.wfile)
|
||||
|
||||
def set_cache_header(self):
|
||||
""" Add cache headers if not in development """
|
||||
if not self.server.development:
|
||||
# 1 year in seconds
|
||||
cache_time = 365 * 86400
|
||||
|
||||
self.send_header(
|
||||
HTTP_HEADER_CACHE_CONTROL,
|
||||
"public, max-age={}".format(cache_time))
|
||||
self.send_header(
|
||||
HTTP_HEADER_EXPIRES,
|
||||
self.date_time_string(time.time()+cache_time))
|
||||
|
||||
def set_session_cookie_header(self):
|
||||
""" Add the header for the session cookie """
|
||||
if self.server.sessions.enabled and self._session is not None:
|
||||
existing_sess_id = self.get_current_session_id()
|
||||
|
||||
if existing_sess_id != self._session.session_id:
|
||||
self.send_header(
|
||||
'Set-Cookie',
|
||||
SESSION_KEY+'='+self._session.session_id)
|
||||
|
||||
def get_session(self):
|
||||
""" Get the requested session object from cookie value """
|
||||
if self.server.sessions.enabled is not True:
|
||||
return None
|
||||
|
||||
session_id = self.get_current_session_id()
|
||||
if session_id is not None:
|
||||
session = self.server.sessions.get(session_id)
|
||||
if session is not None:
|
||||
session.reset_expiry()
|
||||
return session
|
||||
|
||||
return None
|
||||
|
||||
def get_current_session_id(self):
|
||||
"""
|
||||
Extracts the current session id from the
|
||||
cookie or returns None if not set
|
||||
"""
|
||||
cookie = cookies.SimpleCookie()
|
||||
|
||||
if self.headers.get('Cookie', None) is not None:
|
||||
cookie.load(self.headers.get("Cookie"))
|
||||
|
||||
if cookie.get(SESSION_KEY, False):
|
||||
return cookie[SESSION_KEY].value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ServerSession:
|
||||
""" A very simple session class """
|
||||
def __init__(self, session_id):
|
||||
""" Set up the expiry time on creation """
|
||||
self._expiry = 0
|
||||
self.reset_expiry()
|
||||
self.cookie_values = {}
|
||||
self.session_id = session_id
|
||||
|
||||
def reset_expiry(self):
|
||||
""" Resets the expiry based on current time """
|
||||
self._expiry = date_util.utcnow() + timedelta(
|
||||
seconds=SESSION_TIMEOUT_SECONDS)
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
""" Return true if the session is expired based on the expiry time """
|
||||
return self._expiry < date_util.utcnow()
|
||||
|
||||
|
||||
class SessionStore:
|
||||
""" Responsible for storing and retrieving http sessions """
|
||||
def __init__(self, enabled=True):
|
||||
""" Set up the session store """
|
||||
self._sessions = {}
|
||||
self.enabled = enabled
|
||||
self.session_lock = threading.RLock()
|
||||
|
||||
@Throttle(MIN_SEC_SESSION_CLEARING)
|
||||
def remove_expired(self):
|
||||
""" Remove any expired sessions. """
|
||||
if self.session_lock.acquire(False):
|
||||
try:
|
||||
keys = []
|
||||
for key in self._sessions.keys():
|
||||
keys.append(key)
|
||||
|
||||
for key in keys:
|
||||
if self._sessions[key].is_expired:
|
||||
del self._sessions[key]
|
||||
_LOGGER.info("Cleared expired session %s", key)
|
||||
finally:
|
||||
self.session_lock.release()
|
||||
|
||||
def add(self, key, session):
|
||||
""" Add a new session to the list of tracked sessions """
|
||||
self.remove_expired()
|
||||
with self.session_lock:
|
||||
self._sessions[key] = session
|
||||
|
||||
def get(self, key):
|
||||
""" get a session by key """
|
||||
self.remove_expired()
|
||||
session = self._sessions.get(key, None)
|
||||
if session is not None and session.is_expired:
|
||||
return None
|
||||
return session
|
||||
|
||||
def create(self, api_password):
|
||||
""" Creates a new session and adds it to the sessions """
|
||||
if self.enabled is not True:
|
||||
return None
|
||||
|
||||
chars = string.ascii_letters + string.digits
|
||||
session_id = ''.join([random.choice(chars) for i in range(20)])
|
||||
session = ServerSession(session_id)
|
||||
session.cookie_values[CONF_API_PASSWORD] = api_password
|
||||
self.add(session_id, session)
|
||||
return session
|
||||
@@ -1,689 +0,0 @@
|
||||
"""
|
||||
homeassistant.components.httpinterface
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This module provides an API and a HTTP interface for debug purposes.
|
||||
|
||||
By default it will run on port 8123.
|
||||
|
||||
All API calls have to be accompanied by an 'api_password' parameter and will
|
||||
return JSON. If successful calls will return status code 200 or 201.
|
||||
|
||||
Other status codes that can occur are:
|
||||
- 400 (Bad Request)
|
||||
- 401 (Unauthorized)
|
||||
- 404 (Not Found)
|
||||
- 405 (Method not allowed)
|
||||
|
||||
The api supports the following actions:
|
||||
|
||||
/api/states - GET
|
||||
Returns a list of entities for which a state is available
|
||||
Example result:
|
||||
{
|
||||
"entity_ids": [
|
||||
"Paulus_Nexus_4",
|
||||
"weather.sun",
|
||||
"all_devices"
|
||||
]
|
||||
}
|
||||
|
||||
/api/states/<entity_id> - GET
|
||||
Returns the current state from an entity
|
||||
Example result:
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
|
||||
/api/states/<entity_id> - POST
|
||||
Updates the current state of an entity. Returns status code 201 if successful
|
||||
with location header of updated resource and as body the new state.
|
||||
parameter: new_state - string
|
||||
optional parameter: attributes - JSON encoded object
|
||||
Example result:
|
||||
{
|
||||
"attributes": {
|
||||
"next_rising": "07:04:15 29-10-2013",
|
||||
"next_setting": "18:00:31 29-10-2013"
|
||||
},
|
||||
"entity_id": "weather.sun",
|
||||
"last_changed": "23:24:33 28-10-2013",
|
||||
"state": "below_horizon"
|
||||
}
|
||||
|
||||
/api/events/<event_type> - POST
|
||||
Fires an event with event_type
|
||||
optional parameter: event_data - JSON encoded object
|
||||
Example result:
|
||||
{
|
||||
"message": "Event download_file fired."
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
from urlparse import urlparse, parse_qs
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.util as util
|
||||
|
||||
SERVER_PORT = 8123
|
||||
|
||||
HTTP_OK = 200
|
||||
HTTP_CREATED = 201
|
||||
HTTP_MOVED_PERMANENTLY = 301
|
||||
HTTP_BAD_REQUEST = 400
|
||||
HTTP_UNAUTHORIZED = 401
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_METHOD_NOT_ALLOWED = 405
|
||||
HTTP_UNPROCESSABLE_ENTITY = 422
|
||||
|
||||
URL_ROOT = "/"
|
||||
URL_CHANGE_STATE = "/change_state"
|
||||
URL_FIRE_EVENT = "/fire_event"
|
||||
|
||||
URL_API_STATES = "/api/states"
|
||||
URL_API_STATES_ENTITY = "/api/states/{}"
|
||||
URL_API_EVENTS = "/api/events"
|
||||
URL_API_EVENTS_EVENT = "/api/events/{}"
|
||||
URL_API_SERVICES = "/api/services"
|
||||
URL_API_SERVICES_SERVICE = "/api/services/{}/{}"
|
||||
|
||||
URL_STATIC = "/static/{}"
|
||||
|
||||
|
||||
class HTTPInterface(threading.Thread):
|
||||
""" Provides an HTTP interface for Home Assistant. """
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, bus, statemachine, api_password,
|
||||
server_port=None, server_host=None):
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
self.daemon = True
|
||||
|
||||
if not server_port:
|
||||
server_port = SERVER_PORT
|
||||
|
||||
# If no server host is given, accept all incoming requests
|
||||
if not server_host:
|
||||
server_host = '0.0.0.0'
|
||||
|
||||
self.server = HTTPServer((server_host, server_port), RequestHandler)
|
||||
|
||||
self.server.flash_message = None
|
||||
self.server.logger = logging.getLogger(__name__)
|
||||
self.server.bus = bus
|
||||
self.server.statemachine = statemachine
|
||||
self.server.api_password = api_password
|
||||
|
||||
bus.listen_once_event(ha.EVENT_HOMEASSISTANT_START,
|
||||
lambda event: self.start())
|
||||
|
||||
def run(self):
|
||||
""" Start the HTTP interface. """
|
||||
self.server.logger.info("Starting")
|
||||
|
||||
self.server.serve_forever()
|
||||
|
||||
|
||||
class RequestHandler(BaseHTTPRequestHandler):
|
||||
""" Handles incoming HTTP requests """
|
||||
|
||||
PATHS = [ # debug interface
|
||||
('GET', '/', '_handle_get_root'),
|
||||
('POST', re.compile(r'/change_state'), '_handle_change_state'),
|
||||
('POST', re.compile(r'/fire_event'), '_handle_fire_event'),
|
||||
('POST', re.compile(r'/call_service'), '_handle_call_service'),
|
||||
|
||||
# /states
|
||||
('GET', '/api/states', '_handle_get_api_states'),
|
||||
('GET',
|
||||
re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
'_handle_get_api_states_entity'),
|
||||
('POST',
|
||||
re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
'_handle_change_state'),
|
||||
|
||||
# /events
|
||||
('GET', '/api/events', '_handle_get_api_events'),
|
||||
('POST',
|
||||
re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'),
|
||||
'_handle_fire_event'),
|
||||
|
||||
# /services
|
||||
('GET', '/api/services', '_handle_get_api_services'),
|
||||
('POST',
|
||||
re.compile((r'/api/services/'
|
||||
r'(?P<domain>[a-zA-Z\._0-9]+)/'
|
||||
r'(?P<service>[a-zA-Z\._0-9]+)')),
|
||||
'_handle_call_service'),
|
||||
|
||||
# Statis files
|
||||
('GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
'_handle_get_static')
|
||||
]
|
||||
|
||||
use_json = False
|
||||
|
||||
def _handle_request(self, method): # pylint: disable=too-many-branches
|
||||
""" Does some common checks and calls appropriate method. """
|
||||
url = urlparse(self.path)
|
||||
|
||||
# Read query input
|
||||
data = parse_qs(url.query)
|
||||
|
||||
# Did we get post input ?
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
|
||||
if content_length:
|
||||
data.update(parse_qs(self.rfile.read(content_length)))
|
||||
|
||||
try:
|
||||
api_password = data['api_password'][0]
|
||||
except KeyError:
|
||||
api_password = ''
|
||||
|
||||
if url.path.startswith('/api/'):
|
||||
self.use_json = True
|
||||
|
||||
# Var to keep track if we found a path that matched a handler but
|
||||
# the method was different
|
||||
path_matched_but_not_method = False
|
||||
|
||||
# Var to hold the handler for this path and method if found
|
||||
handle_request_method = False
|
||||
|
||||
# Check every handler to find matching result
|
||||
for t_method, t_path, t_handler in RequestHandler.PATHS:
|
||||
|
||||
# we either do string-comparison or regular expression matching
|
||||
# pylint: disable=maybe-no-member
|
||||
if isinstance(t_path, str):
|
||||
path_match = url.path == t_path
|
||||
else:
|
||||
path_match = t_path.match(url.path)
|
||||
|
||||
if path_match and method == t_method:
|
||||
# Call the method
|
||||
handle_request_method = getattr(self, t_handler)
|
||||
break
|
||||
|
||||
elif path_match:
|
||||
path_matched_but_not_method = True
|
||||
|
||||
# Did we find a handler for the incoming request?
|
||||
if handle_request_method:
|
||||
|
||||
# Do not enforce api password for static files
|
||||
if handle_request_method == self._handle_get_static or \
|
||||
self._verify_api_password(api_password):
|
||||
|
||||
handle_request_method(path_match, data)
|
||||
|
||||
elif path_matched_but_not_method:
|
||||
self.send_response(HTTP_METHOD_NOT_ALLOWED)
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
|
||||
def do_GET(self): # pylint: disable=invalid-name
|
||||
""" GET request handler. """
|
||||
self._handle_request('GET')
|
||||
|
||||
def do_POST(self): # pylint: disable=invalid-name
|
||||
""" POST request handler. """
|
||||
self._handle_request('POST')
|
||||
|
||||
def _verify_api_password(self, api_password):
|
||||
""" Helper method to verify the API password
|
||||
and take action if incorrect. """
|
||||
if api_password == self.server.api_password:
|
||||
return True
|
||||
|
||||
elif self.use_json:
|
||||
self._message(
|
||||
"API password missing or incorrect.", HTTP_UNAUTHORIZED)
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_OK)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
self.end_headers()
|
||||
|
||||
self.wfile.write((
|
||||
"<html>"
|
||||
"<head><title>Home Assistant</title>"
|
||||
"<link rel='stylesheet' type='text/css' "
|
||||
" href='/static/style.css'>"
|
||||
"<link rel='icon' href='/static/favicon.ico' "
|
||||
" type='image/x-icon' />"
|
||||
"</head>"
|
||||
"<body>"
|
||||
"<div class='container'>"
|
||||
"<form class='form-signin' action='{}' method='GET'>"
|
||||
|
||||
"<input type='text' class='form-control' name='api_password' "
|
||||
" placeholder='API Password for Home Assistant' "
|
||||
" required autofocus>"
|
||||
|
||||
"<button class='btn btn-lg btn-primary btn-block' "
|
||||
" type='submit'>Enter</button>"
|
||||
|
||||
"</form>"
|
||||
"</div>"
|
||||
"</body></html>").format(self.path))
|
||||
|
||||
return False
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _handle_get_root(self, path_match, data):
|
||||
""" Renders the debug interface. """
|
||||
|
||||
write = lambda txt: self.wfile.write(txt.encode("UTF-8")+"\n")
|
||||
|
||||
self.send_response(HTTP_OK)
|
||||
self.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
self.end_headers()
|
||||
|
||||
write(("<html>"
|
||||
"<head><title>Home Assistant</title>"
|
||||
"<link rel='stylesheet' type='text/css' "
|
||||
" href='/static/style.css'>"
|
||||
"<link rel='icon' href='/static/favicon.ico' "
|
||||
" type='image/x-icon' />"
|
||||
"</head>"
|
||||
"<body>"
|
||||
"<div class='container'>"
|
||||
"<div class='page-header'><h1>Home Assistant</h1></div>"))
|
||||
|
||||
# Flash message support
|
||||
if self.server.flash_message:
|
||||
write(("<div class='row'><div class='col-xs-12'>"
|
||||
"<div class='alert alert-success'>"
|
||||
"{}</div></div></div>").format(self.server.flash_message))
|
||||
|
||||
self.server.flash_message = None
|
||||
|
||||
# Describe state machine:
|
||||
write(("<div class='row'>"
|
||||
"<div class='col-xs-12'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" States</h2></div>"
|
||||
"<form method='post' action='/change_state' "
|
||||
" class='form-change-state'>"
|
||||
"<input type='hidden' name='api_password' value='{}'>"
|
||||
"<table class='table'><tr>"
|
||||
"<th>Entity ID</th><th>State</th>"
|
||||
"<th>Attributes</th><th>Last Changed</th>"
|
||||
"</tr>").format(self.server.api_password))
|
||||
|
||||
for entity_id in \
|
||||
sorted(self.server.statemachine.entity_ids,
|
||||
key=lambda key: key.lower()):
|
||||
|
||||
state = self.server.statemachine.get_state(entity_id)
|
||||
|
||||
attributes = u"<br>".join(
|
||||
[u"{}: {}".format(attr, state.attributes[attr])
|
||||
for attr in state.attributes])
|
||||
|
||||
write((u"<tr>"
|
||||
u"<td>{}</td><td>{}</td><td>{}</td><td>{}</td>"
|
||||
u"</tr>").format(
|
||||
entity_id,
|
||||
state.state,
|
||||
attributes,
|
||||
util.datetime_to_str(state.last_changed)))
|
||||
|
||||
# Change state form
|
||||
write(("<tr><td><input name='entity_id' class='form-control' "
|
||||
" placeholder='Entity ID'></td>"
|
||||
"<td><input name='new_state' class='form-control' "
|
||||
" placeholder='New State'></td>"
|
||||
"<td><textarea rows='3' name='attributes' class='form-control' "
|
||||
" placeholder='State Attributes (JSON, optional)'>"
|
||||
"</textarea></td>"
|
||||
"<td><button type='submit' class='btn btn-default'>"
|
||||
"Set State</button></td></tr>"
|
||||
|
||||
"</table></form></div>"
|
||||
|
||||
"</div></div>"))
|
||||
|
||||
# Describe bus/services:
|
||||
write(("<div class='row'>"
|
||||
"<div class='col-xs-6'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" Services</h2></div>"
|
||||
"<table class='table'>"
|
||||
"<tr><th>Domain</th><th>Service</th></tr>"))
|
||||
|
||||
for domain, services in sorted(
|
||||
self.server.bus.services.items()):
|
||||
write("<tr><td>{}</td><td>{}</td></tr>".format(
|
||||
domain, ", ".join(services)))
|
||||
|
||||
write(("</table></div></div>"
|
||||
|
||||
"<div class='col-xs-6'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" Call Service</h2></div>"
|
||||
"<div class='panel-body'>"
|
||||
"<form method='post' action='/call_service' "
|
||||
" class='form-horizontal form-fire-event'>"
|
||||
"<input type='hidden' name='api_password' value='{}'>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='domain' class='col-xs-3 control-label'>"
|
||||
" Domain</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <input type='text' class='form-control' id='domain'"
|
||||
" name='domain' placeholder='Service Domain'>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='service' class='col-xs-3 control-label'>"
|
||||
" Service</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <input type='text' class='form-control' id='service'"
|
||||
" name='service' placeholder='Service name'>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='service_data' class='col-xs-3 control-label'>"
|
||||
" Service data</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <textarea rows='3' class='form-control' id='service_data'"
|
||||
" name='service_data' placeholder='Service Data "
|
||||
"(JSON, optional)'></textarea>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <div class='col-xs-offset-3 col-xs-9'>"
|
||||
" <button type='submit' class='btn btn-default'>"
|
||||
" Call Service</button>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
"</form>"
|
||||
"</div></div></div>"
|
||||
"</div>").format(self.server.api_password))
|
||||
|
||||
# Describe bus/events:
|
||||
write(("<div class='row'>"
|
||||
"<div class='col-xs-6'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" Events</h2></div>"
|
||||
"<table class='table'>"
|
||||
"<tr><th>Event</th><th>Listeners</th></tr>"))
|
||||
|
||||
for event, listener_count in sorted(
|
||||
self.server.bus.event_listeners.items()):
|
||||
write("<tr><td>{}</td><td>{}</td></tr>".format(
|
||||
event, listener_count))
|
||||
|
||||
write(("</table></div></div>"
|
||||
|
||||
"<div class='col-xs-6'>"
|
||||
"<div class='panel panel-primary'>"
|
||||
"<div class='panel-heading'><h2 class='panel-title'>"
|
||||
" Fire Event</h2></div>"
|
||||
"<div class='panel-body'>"
|
||||
"<form method='post' action='/fire_event' "
|
||||
" class='form-horizontal form-fire-event'>"
|
||||
"<input type='hidden' name='api_password' value='{}'>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='event_type' class='col-xs-3 control-label'>"
|
||||
" Event type</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <input type='text' class='form-control' id='event_type'"
|
||||
" name='event_type' placeholder='Event Type'>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <label for='event_data' class='col-xs-3 control-label'>"
|
||||
" Event data</label>"
|
||||
" <div class='col-xs-9'>"
|
||||
" <textarea rows='3' class='form-control' id='event_data'"
|
||||
" name='event_data' placeholder='Event Data "
|
||||
"(JSON, optional)'></textarea>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
|
||||
"<div class='form-group'>"
|
||||
" <div class='col-xs-offset-3 col-xs-9'>"
|
||||
" <button type='submit' class='btn btn-default'>"
|
||||
" Fire Event</button>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
"</form>"
|
||||
"</div></div></div>"
|
||||
"</div>").format(self.server.api_password))
|
||||
|
||||
write("</div></body></html>")
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_change_state(self, path_match, data):
|
||||
""" Handles updating the state of an entity.
|
||||
|
||||
This handles the following paths:
|
||||
/change_state
|
||||
/api/states/<entity_id>
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
entity_id = path_match.group('entity_id')
|
||||
except IndexError:
|
||||
# If group 'entity_id' does not exist in path_match
|
||||
entity_id = data['entity_id'][0]
|
||||
|
||||
new_state = data['new_state'][0]
|
||||
|
||||
try:
|
||||
attributes = json.loads(data['attributes'][0])
|
||||
except KeyError:
|
||||
# Happens if key 'attributes' does not exist
|
||||
attributes = None
|
||||
|
||||
# Write state
|
||||
self.server.statemachine.set_state(entity_id,
|
||||
new_state,
|
||||
attributes)
|
||||
|
||||
# Return state if json, else redirect to main page
|
||||
if self.use_json:
|
||||
state = self.server.statemachine.get_state(entity_id)
|
||||
|
||||
self._write_json(state.as_dict(),
|
||||
status_code=HTTP_CREATED,
|
||||
location=
|
||||
URL_API_STATES_ENTITY.format(entity_id))
|
||||
else:
|
||||
self._message(
|
||||
"State of {} changed to {}".format(entity_id, new_state))
|
||||
|
||||
except KeyError:
|
||||
# If new_state don't exist in post data
|
||||
self._message(
|
||||
"No new_state submitted.", HTTP_BAD_REQUEST)
|
||||
|
||||
except ValueError:
|
||||
# Occurs during error parsing json
|
||||
self._message(
|
||||
"Invalid JSON for attributes", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_fire_event(self, path_match, data):
|
||||
""" Handles firing of an event.
|
||||
|
||||
This handles the following paths:
|
||||
/fire_event
|
||||
/api/events/<event_type>
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
event_type = path_match.group('event_type')
|
||||
except IndexError:
|
||||
# If group event_type does not exist in path_match
|
||||
event_type = data['event_type'][0]
|
||||
|
||||
try:
|
||||
event_data = json.loads(data['event_data'][0])
|
||||
except KeyError:
|
||||
# Happens if key 'event_data' does not exist
|
||||
event_data = None
|
||||
|
||||
self.server.bus.fire_event(event_type, event_data)
|
||||
|
||||
self._message("Event {} fired.".format(event_type))
|
||||
|
||||
except KeyError:
|
||||
# Occurs if event_type does not exist in data
|
||||
self._message("No event_type received.", HTTP_BAD_REQUEST)
|
||||
|
||||
except ValueError:
|
||||
# Occurs during error parsing json
|
||||
self._message(
|
||||
"Invalid JSON for event_data", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
def _handle_call_service(self, path_match, data):
|
||||
""" Handles calling a service.
|
||||
|
||||
This handles the following paths:
|
||||
/call_service
|
||||
/api/services/<domain>/<service>
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
domain = path_match.group('domain')
|
||||
service = path_match.group('service')
|
||||
except IndexError:
|
||||
# If group domain or service does not exist in path_match
|
||||
domain = data['domain'][0]
|
||||
service = data['service'][0]
|
||||
|
||||
try:
|
||||
service_data = json.loads(data['service_data'][0])
|
||||
except KeyError:
|
||||
# Happens if key 'service_data' does not exist
|
||||
service_data = None
|
||||
|
||||
self.server.bus.call_service(domain, service, service_data)
|
||||
|
||||
self._message("Service {}/{} called.".format(domain, service))
|
||||
|
||||
except ha.ServiceDoesNotExistError:
|
||||
# If the service does not exist
|
||||
self._message('Service does not exist', HTTP_BAD_REQUEST)
|
||||
|
||||
except KeyError:
|
||||
# Occurs if domain or service does not exist in data
|
||||
self._message("No domain or service received.", HTTP_BAD_REQUEST)
|
||||
|
||||
except ValueError:
|
||||
# Occurs during error parsing json
|
||||
self._message(
|
||||
"Invalid JSON for service_data", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _handle_get_api_states(self, path_match, data):
|
||||
""" Returns the entitie ids which state are being tracked. """
|
||||
self._write_json({'entity_ids': self.server.statemachine.entity_ids})
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _handle_get_api_states_entity(self, path_match, data):
|
||||
""" Returns the state of a specific entity. """
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
state = self.server.statemachine.get_state(entity_id)
|
||||
|
||||
try:
|
||||
self._write_json(state.as_dict())
|
||||
except AttributeError:
|
||||
# If state for entity_id does not exist
|
||||
self._message("State does not exist.", HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
def _handle_get_api_events(self, path_match, data):
|
||||
""" Handles getting overview of event listeners. """
|
||||
self._write_json({'event_listeners': self.server.bus.event_listeners})
|
||||
|
||||
def _handle_get_api_services(self, path_match, data):
|
||||
""" Handles getting overview of services. """
|
||||
self._write_json({'services': self.server.bus.services})
|
||||
|
||||
def _handle_get_static(self, path_match, data):
|
||||
""" Returns a static file. """
|
||||
req_file = util.sanitize_filename(path_match.group('file'))
|
||||
|
||||
path = os.path.join(os.path.dirname(__file__), 'www_static', req_file)
|
||||
|
||||
if os.path.isfile(path):
|
||||
self.send_response(HTTP_OK)
|
||||
|
||||
# TODO: correct header for mime-type and caching
|
||||
|
||||
self.end_headers()
|
||||
|
||||
with open(path, 'rb') as inp:
|
||||
data = inp.read(1024)
|
||||
|
||||
while data:
|
||||
self.wfile.write(data)
|
||||
|
||||
data = inp.read(1024)
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
self.end_headers()
|
||||
|
||||
def _message(self, message, status_code=HTTP_OK):
|
||||
""" Helper method to return a message to the caller. """
|
||||
if self.use_json:
|
||||
self._write_json({'message': message}, status_code=status_code)
|
||||
elif status_code == HTTP_OK:
|
||||
self.server.flash_message = message
|
||||
self._redirect('/')
|
||||
else:
|
||||
self.send_error(status_code, message)
|
||||
|
||||
def _redirect(self, location):
|
||||
""" Helper method to redirect caller. """
|
||||
self.send_response(HTTP_MOVED_PERMANENTLY)
|
||||
|
||||
self.send_header(
|
||||
"Location", "{}?api_password={}".format(
|
||||
location, self.server.api_password))
|
||||
|
||||
self.end_headers()
|
||||
|
||||
def _write_json(self, data=None, status_code=HTTP_OK, location=None):
|
||||
""" Helper method to return JSON to the caller. """
|
||||
self.send_response(status_code)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
|
||||
if location:
|
||||
self.send_header('Location', location)
|
||||
|
||||
self.end_headers()
|
||||
|
||||
if data:
|
||||
self.wfile.write(json.dumps(data, indent=4, sort_keys=True))
|
||||
|
Before Width: | Height: | Size: 318 B |
@@ -1,39 +0,0 @@
|
||||
@import url(//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.0.2/css/bootstrap.min.css);
|
||||
|
||||
.panel > form, .panel > form > .table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.panel .table {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.form-signin {
|
||||
max-width: 330px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-signin .form-control {
|
||||
margin-top: 40px;
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
height: auto;
|
||||
padding: 10px;
|
||||
margin-bottom: -1px;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-signin .btn-primary {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.form-fire-event {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.form-fire-event .form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
79
homeassistant/components/ifttt.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
homeassistant.components.ifttt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
This component enable you to trigger Maker IFTTT recipes.
|
||||
Check https://ifttt.com/maker for details.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use Maker IFTTT you will need to add something like the following to your
|
||||
config/configuration.yaml.
|
||||
|
||||
ifttt:
|
||||
key: xxxxx-x-xxxxxxxxxxxxx
|
||||
|
||||
Variables:
|
||||
|
||||
key
|
||||
*Required
|
||||
Your api key
|
||||
|
||||
"""
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "ifttt"
|
||||
|
||||
SERVICE_TRIGGER = 'trigger'
|
||||
|
||||
ATTR_EVENT = 'event'
|
||||
ATTR_VALUE1 = 'value1'
|
||||
ATTR_VALUE2 = 'value2'
|
||||
ATTR_VALUE3 = 'value3'
|
||||
|
||||
DEPENDENCIES = []
|
||||
|
||||
REQUIREMENTS = ['pyfttt==0.3']
|
||||
|
||||
|
||||
def trigger(hass, event, value1=None, value2=None, value3=None):
|
||||
""" Trigger a Maker IFTTT recipe """
|
||||
data = {
|
||||
ATTR_EVENT: event,
|
||||
ATTR_VALUE1: value1,
|
||||
ATTR_VALUE2: value2,
|
||||
ATTR_VALUE3: value3,
|
||||
}
|
||||
hass.services.call(DOMAIN, SERVICE_TRIGGER, data)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup the ifttt service component """
|
||||
|
||||
if not validate_config(config, {DOMAIN: ['key']}, _LOGGER):
|
||||
return False
|
||||
|
||||
key = config[DOMAIN]['key']
|
||||
|
||||
def trigger_service(call):
|
||||
""" Handle ifttt trigger service calls. """
|
||||
event = call.data.get(ATTR_EVENT)
|
||||
value1 = call.data.get(ATTR_VALUE1)
|
||||
value2 = call.data.get(ATTR_VALUE2)
|
||||
value3 = call.data.get(ATTR_VALUE3)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
try:
|
||||
import pyfttt as pyfttt
|
||||
pyfttt.send_event(key, event, value1, value2, value3)
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.exception("Error communicating with IFTTT")
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service)
|
||||
|
||||
return True
|
||||
44
homeassistant/components/introduction.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
homeassistant.components.introduction
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Component that will help guide the user taking its first steps.
|
||||
"""
|
||||
import logging
|
||||
|
||||
DOMAIN = 'introduction'
|
||||
DEPENDENCIES = []
|
||||
|
||||
|
||||
def setup(hass, config=None):
|
||||
""" Setup the introduction component. """
|
||||
log = logging.getLogger(__name__)
|
||||
log.info("""
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Hello, and welcome to Home Assistant!
|
||||
|
||||
We'll hope that we can make all your dreams come true.
|
||||
|
||||
Here are some resources to get started:
|
||||
|
||||
- Configuring Home Assistant:
|
||||
https://home-assistant.io/getting-started/configuration.html
|
||||
|
||||
- Available components:
|
||||
https://home-assistant.io/components/
|
||||
|
||||
- Troubleshooting your configuration:
|
||||
https://home-assistant.io/getting-started/troubleshooting-configuration.html
|
||||
|
||||
- Getting help:
|
||||
https://home-assistant.io/help/
|
||||
|
||||
This message is generated by the introduction component. You can
|
||||
disable it in configuration.yaml.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
""")
|
||||
|
||||
return True
|
||||
232
homeassistant/components/isy994.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
homeassistant.components.isy994
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Connects to an ISY-994 controller and loads relevant components to control its
|
||||
devices. Also contains the base classes for ISY Sensors, Lights, and Switches.
|
||||
|
||||
For configuration details please visit the documentation for this component at
|
||||
https://home-assistant.io/components/isy994.html
|
||||
"""
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant import bootstrap
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_USERNAME, CONF_PASSWORD, EVENT_PLATFORM_DISCOVERED,
|
||||
EVENT_HOMEASSISTANT_STOP, ATTR_SERVICE, ATTR_DISCOVERED,
|
||||
ATTR_FRIENDLY_NAME)
|
||||
|
||||
DOMAIN = "isy994"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['PyISY==1.0.5']
|
||||
DISCOVER_LIGHTS = "isy994.lights"
|
||||
DISCOVER_SWITCHES = "isy994.switches"
|
||||
DISCOVER_SENSORS = "isy994.sensors"
|
||||
ISY = None
|
||||
SENSOR_STRING = 'Sensor'
|
||||
HIDDEN_STRING = '{HIDE ME}'
|
||||
CONF_TLS_VER = 'tls'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""
|
||||
Setup ISY994 component.
|
||||
This will automatically import associated lights, switches, and sensors.
|
||||
"""
|
||||
try:
|
||||
import PyISY
|
||||
except ImportError:
|
||||
_LOGGER.error("Error while importing dependency PyISY.")
|
||||
return False
|
||||
|
||||
# pylint: disable=global-statement
|
||||
# check for required values in configuration file
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]},
|
||||
_LOGGER):
|
||||
return False
|
||||
|
||||
# pull and parse standard configuration
|
||||
user = config[DOMAIN][CONF_USERNAME]
|
||||
password = config[DOMAIN][CONF_PASSWORD]
|
||||
host = urlparse(config[DOMAIN][CONF_HOST])
|
||||
addr = host.geturl()
|
||||
if host.scheme == 'http':
|
||||
addr = addr.replace('http://', '')
|
||||
https = False
|
||||
elif host.scheme == 'https':
|
||||
addr = addr.replace('https://', '')
|
||||
https = True
|
||||
else:
|
||||
_LOGGER.error('isy994 host value in configuration file is invalid.')
|
||||
return False
|
||||
port = host.port
|
||||
addr = addr.replace(':{}'.format(port), '')
|
||||
|
||||
# pull and parse optional configuration
|
||||
global SENSOR_STRING
|
||||
global HIDDEN_STRING
|
||||
SENSOR_STRING = str(config[DOMAIN].get('sensor_string', SENSOR_STRING))
|
||||
HIDDEN_STRING = str(config[DOMAIN].get('hidden_string', HIDDEN_STRING))
|
||||
tls_version = config[DOMAIN].get(CONF_TLS_VER, None)
|
||||
|
||||
# connect to ISY controller
|
||||
global ISY
|
||||
ISY = PyISY.ISY(addr, port, user, password, use_https=https,
|
||||
tls_ver=tls_version, log=_LOGGER)
|
||||
if not ISY.connected:
|
||||
return False
|
||||
|
||||
# listen for HA stop to disconnect
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
|
||||
|
||||
# Load components for the devices in the ISY controller that we support
|
||||
for comp_name, discovery in ((('sensor', DISCOVER_SENSORS),
|
||||
('light', DISCOVER_LIGHTS),
|
||||
('switch', DISCOVER_SWITCHES))):
|
||||
component = get_component(comp_name)
|
||||
bootstrap.setup_component(hass, component.DOMAIN, config)
|
||||
hass.bus.fire(EVENT_PLATFORM_DISCOVERED,
|
||||
{ATTR_SERVICE: discovery,
|
||||
ATTR_DISCOVERED: {}})
|
||||
|
||||
ISY.auto_update = True
|
||||
return True
|
||||
|
||||
|
||||
def stop(event):
|
||||
""" Cleanup the ISY subscription. """
|
||||
ISY.auto_update = False
|
||||
|
||||
|
||||
class ISYDeviceABC(ToggleEntity):
|
||||
""" Abstract Class for an ISY device. """
|
||||
|
||||
_attrs = {}
|
||||
_onattrs = []
|
||||
_states = []
|
||||
_dtype = None
|
||||
_domain = None
|
||||
_name = None
|
||||
|
||||
def __init__(self, node):
|
||||
# setup properties
|
||||
self.node = node
|
||||
self.hidden = HIDDEN_STRING in self.raw_name
|
||||
|
||||
# track changes
|
||||
self._change_handler = self.node.status. \
|
||||
subscribe('changed', self.on_update)
|
||||
|
||||
def __del__(self):
|
||||
""" cleanup subscriptions because it is the right thing to do. """
|
||||
self._change_handler.unsubscribe()
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
""" Returns the domain of the entity. """
|
||||
return self._domain
|
||||
|
||||
@property
|
||||
def dtype(self):
|
||||
""" Returns the data type of the entity (binary or analog). """
|
||||
if self._dtype in ['analog', 'binary']:
|
||||
return self._dtype
|
||||
return 'binary' if self.unit_of_measurement is None else 'analog'
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" Tells Home Assistant not to poll this entity. """
|
||||
return False
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
""" Returns the unclean value from the controller. """
|
||||
# pylint: disable=protected-access
|
||||
return self.node.status._val
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Returns the state attributes for the node. """
|
||||
attr = {ATTR_FRIENDLY_NAME: self.name}
|
||||
for name, prop in self._attrs.items():
|
||||
attr[name] = getattr(self, prop)
|
||||
attr = self._attr_filter(attr)
|
||||
return attr
|
||||
|
||||
def _attr_filter(self, attr):
|
||||
""" Placeholder for attribute filters. """
|
||||
# pylint: disable=no-self-use
|
||||
return attr
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
""" Returns the id of this ISY sensor. """
|
||||
# pylint: disable=protected-access
|
||||
return self.node._id
|
||||
|
||||
@property
|
||||
def raw_name(self):
|
||||
""" Returns the unclean node name. """
|
||||
return str(self._name) \
|
||||
if self._name is not None else str(self.node.name)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the cleaned name of the node. """
|
||||
return self.raw_name.replace(HIDDEN_STRING, '').strip() \
|
||||
.replace('_', ' ')
|
||||
|
||||
def update(self):
|
||||
""" Update state of the sensor. """
|
||||
# ISY objects are automatically updated by the ISY's event stream
|
||||
pass
|
||||
|
||||
def on_update(self, event):
|
||||
""" Handles the update received event. """
|
||||
self.update_ha_state()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" Returns boolean response if the node is on. """
|
||||
return bool(self.value)
|
||||
|
||||
@property
|
||||
def is_open(self):
|
||||
""" Returns boolean respons if the node is open. On = Open. """
|
||||
return self.is_on
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the node. """
|
||||
if len(self._states) > 0:
|
||||
return self._states[0] if self.is_on else self._states[1]
|
||||
return self.value
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turns the device on. """
|
||||
if self.domain is not 'sensor':
|
||||
attrs = [kwargs.get(name) for name in self._onattrs]
|
||||
self.node.on(*attrs)
|
||||
else:
|
||||
_LOGGER.error('ISY cannot turn on sensors.')
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turns the device off. """
|
||||
if self.domain is not 'sensor':
|
||||
self.node.off()
|
||||
else:
|
||||
_LOGGER.error('ISY cannot turn off sensors.')
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
""" Returns the defined units of measurement or None. """
|
||||
try:
|
||||
return self.node.units
|
||||
except AttributeError:
|
||||
return None
|
||||
@@ -6,76 +6,82 @@ Provides functionality to emulate keyboard presses on host machine.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import homeassistant.components as components
|
||||
from homeassistant.const import (
|
||||
SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE,
|
||||
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
SERVICE_MEDIA_PLAY_PAUSE)
|
||||
|
||||
|
||||
DOMAIN = "keyboard"
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['pyuserinput==0.1.9']
|
||||
|
||||
|
||||
def volume_up(bus):
|
||||
def volume_up(hass):
|
||||
""" Press the keyboard button for volume up. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_UP)
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_UP)
|
||||
|
||||
|
||||
def volume_down(bus):
|
||||
def volume_down(hass):
|
||||
""" Press the keyboard button for volume down. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_DOWN)
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN)
|
||||
|
||||
|
||||
def volume_mute(bus):
|
||||
def volume_mute(hass):
|
||||
""" Press the keyboard button for muting volume. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_VOLUME_MUTE)
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE)
|
||||
|
||||
|
||||
def media_play_pause(bus):
|
||||
def media_play_pause(hass):
|
||||
""" Press the keyboard button for play/pause. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_PLAY_PAUSE)
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE)
|
||||
|
||||
|
||||
def media_next_track(bus):
|
||||
def media_next_track(hass):
|
||||
""" Press the keyboard button for next track. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_NEXT_TRACK)
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK)
|
||||
|
||||
|
||||
def media_prev_track(bus):
|
||||
def media_prev_track(hass):
|
||||
""" Press the keyboard button for prev track. """
|
||||
bus.call_service(DOMAIN, components.SERVICE_MEDIA_PREV_TRACK)
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK)
|
||||
|
||||
|
||||
def setup(bus):
|
||||
def setup(hass, config):
|
||||
""" Listen for keyboard events. """
|
||||
try:
|
||||
import pykeyboard
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).exception(
|
||||
"MediaButtons: Error while importing dependency PyUserInput.")
|
||||
"Error while importing dependency PyUserInput.")
|
||||
|
||||
return False
|
||||
|
||||
keyboard = pykeyboard.PyKeyboard()
|
||||
keyboard.special_key_assignment()
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_UP,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_up_key))
|
||||
hass.services.register(DOMAIN, SERVICE_VOLUME_UP,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_up_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_DOWN,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_down_key))
|
||||
hass.services.register(DOMAIN, SERVICE_VOLUME_DOWN,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_down_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_VOLUME_MUTE,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_mute_key))
|
||||
hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.volume_mute_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_PLAY_PAUSE,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_play_pause_key))
|
||||
hass.services.register(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_play_pause_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_NEXT_TRACK,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_next_track_key))
|
||||
hass.services.register(DOMAIN, SERVICE_MEDIA_NEXT_TRACK,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_next_track_key))
|
||||
|
||||
bus.register_service(DOMAIN, components.SERVICE_MEDIA_PREV_TRACK,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_prev_track_key))
|
||||
hass.services.register(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
lambda service:
|
||||
keyboard.tap_key(keyboard.media_prev_track_key))
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
homeassistant.components.light
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to interact with lights.
|
||||
|
||||
@@ -49,30 +49,28 @@ Supports following parameters:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
from collections import namedtuple
|
||||
import os
|
||||
import csv
|
||||
|
||||
import homeassistant as ha
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
|
||||
import homeassistant.util as util
|
||||
from homeassistant.components import (group, extract_entity_ids,
|
||||
STATE_ON, STATE_OFF,
|
||||
SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
||||
ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME)
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.const import (
|
||||
STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID)
|
||||
from homeassistant.components import group, discovery, wink, isy994
|
||||
|
||||
|
||||
DOMAIN = "light"
|
||||
DEPENDENCIES = []
|
||||
SCAN_INTERVAL = 30
|
||||
|
||||
GROUP_NAME_ALL_LIGHTS = 'all_lights'
|
||||
ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format(
|
||||
GROUP_NAME_ALL_LIGHTS)
|
||||
GROUP_NAME_ALL_LIGHTS = 'all lights'
|
||||
ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format('all_lights')
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
# integer that represents transition time in seconds to make change
|
||||
ATTR_TRANSITION = "transition"
|
||||
|
||||
@@ -86,157 +84,107 @@ ATTR_BRIGHTNESS = "brightness"
|
||||
# String representing a profile (built-in ones or external defined)
|
||||
ATTR_PROFILE = "profile"
|
||||
|
||||
# If the light should flash, can be FLASH_SHORT or FLASH_LONG
|
||||
ATTR_FLASH = "flash"
|
||||
FLASH_SHORT = "short"
|
||||
FLASH_LONG = "long"
|
||||
|
||||
# Apply an effect to the light, can be EFFECT_COLORLOOP
|
||||
ATTR_EFFECT = "effect"
|
||||
EFFECT_COLORLOOP = "colorloop"
|
||||
|
||||
LIGHT_PROFILES_FILE = "light_profiles.csv"
|
||||
|
||||
# Maps discovered services to their platforms
|
||||
DISCOVERY_PLATFORMS = {
|
||||
wink.DISCOVER_LIGHTS: 'wink',
|
||||
isy994.DISCOVER_LIGHTS: 'isy994',
|
||||
discovery.SERVICE_HUE: 'hue',
|
||||
}
|
||||
|
||||
def is_on(statemachine, entity_id=None):
|
||||
PROP_TO_ATTR = {
|
||||
'brightness': ATTR_BRIGHTNESS,
|
||||
'color_xy': ATTR_XY_COLOR,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_on(hass, entity_id=None):
|
||||
""" Returns if the lights are on based on the statemachine. """
|
||||
entity_id = entity_id or ENTITY_ID_ALL_LIGHTS
|
||||
|
||||
return statemachine.is_state(entity_id, STATE_ON)
|
||||
return hass.states.is_state(entity_id, STATE_ON)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def turn_on(bus, entity_id=None, transition=None, brightness=None,
|
||||
rgb_color=None, xy_color=None, profile=None):
|
||||
def turn_on(hass, entity_id=None, transition=None, brightness=None,
|
||||
rgb_color=None, xy_color=None, profile=None, flash=None,
|
||||
effect=None):
|
||||
""" Turns all or specified light on. """
|
||||
data = {}
|
||||
data = {
|
||||
key: value for key, value in [
|
||||
(ATTR_ENTITY_ID, entity_id),
|
||||
(ATTR_PROFILE, profile),
|
||||
(ATTR_TRANSITION, transition),
|
||||
(ATTR_BRIGHTNESS, brightness),
|
||||
(ATTR_RGB_COLOR, rgb_color),
|
||||
(ATTR_XY_COLOR, xy_color),
|
||||
(ATTR_FLASH, flash),
|
||||
(ATTR_EFFECT, effect),
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
if profile:
|
||||
data[ATTR_PROFILE] = profile
|
||||
|
||||
if transition is not None:
|
||||
data[ATTR_TRANSITION] = transition
|
||||
|
||||
if brightness is not None:
|
||||
data[ATTR_BRIGHTNESS] = brightness
|
||||
|
||||
if rgb_color:
|
||||
data[ATTR_RGB_COLOR] = rgb_color
|
||||
|
||||
if xy_color:
|
||||
data[ATTR_XY_COLOR] = xy_color
|
||||
|
||||
bus.call_service(DOMAIN, SERVICE_TURN_ON, data)
|
||||
hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
|
||||
|
||||
|
||||
def turn_off(bus, entity_id=None, transition=None):
|
||||
def turn_off(hass, entity_id=None, transition=None):
|
||||
""" Turns all or specified light off. """
|
||||
data = {}
|
||||
data = {
|
||||
key: value for key, value in [
|
||||
(ATTR_ENTITY_ID, entity_id),
|
||||
(ATTR_TRANSITION, transition),
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
if transition is not None:
|
||||
data[ATTR_TRANSITION] = transition
|
||||
|
||||
bus.call_service(DOMAIN, SERVICE_TURN_OFF, data)
|
||||
hass.services.call(DOMAIN, SERVICE_TURN_OFF, data)
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-locals
|
||||
def setup(bus, statemachine, light_control):
|
||||
def setup(hass, config):
|
||||
""" Exposes light control via statemachine and services. """
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ent_to_light = {}
|
||||
light_to_ent = {}
|
||||
|
||||
def _update_light_state(light_id, light_state):
|
||||
""" Update statemachine based on the LightState passed in. """
|
||||
name = light_control.get_name(light_id) or "Unknown Light"
|
||||
|
||||
try:
|
||||
entity_id = light_to_ent[light_id]
|
||||
except KeyError:
|
||||
# We have not seen this light before, set it up
|
||||
|
||||
# Create entity id
|
||||
logger.info(u"Found new light {}".format(name))
|
||||
|
||||
entity_id = util.ensure_unique_string(
|
||||
ENTITY_ID_FORMAT.format(util.slugify(name)),
|
||||
ent_to_light.keys())
|
||||
|
||||
ent_to_light[entity_id] = light_id
|
||||
light_to_ent[light_id] = entity_id
|
||||
|
||||
state_attr = {ATTR_FRIENDLY_NAME: name}
|
||||
|
||||
if light_state.on:
|
||||
state = STATE_ON
|
||||
|
||||
if light_state.brightness:
|
||||
state_attr[ATTR_BRIGHTNESS] = light_state.brightness
|
||||
|
||||
if light_state.color:
|
||||
state_attr[ATTR_XY_COLOR] = light_state.color
|
||||
|
||||
else:
|
||||
state = STATE_OFF
|
||||
|
||||
statemachine.set_state(entity_id, state, state_attr)
|
||||
|
||||
def update_light_state(light_id):
|
||||
""" Update the state of specified light. """
|
||||
_update_light_state(light_id, light_control.get_state(light_id))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def update_lights_state(time, force_reload=False):
|
||||
""" Update the state of all the lights. """
|
||||
|
||||
# First time this method gets called, force_reload should be True
|
||||
if (force_reload or
|
||||
datetime.now() - update_lights_state.last_updated >
|
||||
MIN_TIME_BETWEEN_SCANS):
|
||||
|
||||
logger.info("Updating light status")
|
||||
update_lights_state.last_updated = datetime.now()
|
||||
|
||||
for light_id, light_state in light_control.get_states().items():
|
||||
_update_light_state(light_id, light_state)
|
||||
|
||||
# Update light state and discover lights for tracking the group
|
||||
update_lights_state(None, True)
|
||||
|
||||
if len(ent_to_light) == 0:
|
||||
logger.error("No lights found")
|
||||
return False
|
||||
|
||||
# Track all lights in a group
|
||||
group.setup(bus, statemachine,
|
||||
GROUP_NAME_ALL_LIGHTS, light_to_ent.values())
|
||||
component = EntityComponent(
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS,
|
||||
GROUP_NAME_ALL_LIGHTS)
|
||||
component.setup(config)
|
||||
|
||||
# Load built-in profiles and custom profiles
|
||||
profile_paths = [os.path.dirname(__file__), os.getcwd()]
|
||||
profile_paths = [os.path.join(os.path.dirname(__file__),
|
||||
LIGHT_PROFILES_FILE),
|
||||
hass.config.path(LIGHT_PROFILES_FILE)]
|
||||
profiles = {}
|
||||
|
||||
for dir_path in profile_paths:
|
||||
file_path = os.path.join(dir_path, LIGHT_PROFILES_FILE)
|
||||
for profile_path in profile_paths:
|
||||
if not os.path.isfile(profile_path):
|
||||
continue
|
||||
with open(profile_path) as inp:
|
||||
reader = csv.reader(inp)
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
with open(file_path, 'rb') as inp:
|
||||
reader = csv.reader(inp)
|
||||
# Skip the header
|
||||
next(reader, None)
|
||||
|
||||
# Skip the header
|
||||
next(reader, None)
|
||||
try:
|
||||
for profile_id, color_x, color_y, brightness in reader:
|
||||
profiles[profile_id] = (float(color_x), float(color_y),
|
||||
int(brightness))
|
||||
except ValueError:
|
||||
# ValueError if not 4 values per row
|
||||
# ValueError if convert to float/int failed
|
||||
_LOGGER.error(
|
||||
"Error parsing light profiles from %s", profile_path)
|
||||
|
||||
try:
|
||||
for profile_id, color_x, color_y, brightness in reader:
|
||||
profiles[profile_id] = (float(color_x), float(color_y),
|
||||
int(brightness))
|
||||
|
||||
except ValueError:
|
||||
# ValueError if not 4 values per row
|
||||
# ValueError if convert to float/int failed
|
||||
logger.error(
|
||||
"Error parsing light profiles from {}".format(
|
||||
file_path))
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
def handle_light_service(service):
|
||||
""" Hande a turn light on or off service call. """
|
||||
@@ -244,215 +192,131 @@ def setup(bus, statemachine, light_control):
|
||||
dat = service.data
|
||||
|
||||
# Convert the entity ids to valid light ids
|
||||
light_ids = [ent_to_light[entity_id] for entity_id
|
||||
in extract_entity_ids(statemachine, service)
|
||||
if entity_id in ent_to_light]
|
||||
target_lights = component.extract_from_service(service)
|
||||
|
||||
if not light_ids:
|
||||
light_ids = ent_to_light.values()
|
||||
params = {}
|
||||
|
||||
transition = util.convert(dat.get(ATTR_TRANSITION), int)
|
||||
|
||||
if transition is not None:
|
||||
params[ATTR_TRANSITION] = transition
|
||||
|
||||
if service.service == SERVICE_TURN_OFF:
|
||||
light_control.turn_light_off(light_ids, transition)
|
||||
for light in target_lights:
|
||||
light.turn_off(**params)
|
||||
|
||||
else:
|
||||
# Processing extra data for turn light on request
|
||||
for light in target_lights:
|
||||
if light.should_poll:
|
||||
light.update_ha_state(True)
|
||||
return
|
||||
|
||||
# We process the profile first so that we get the desired
|
||||
# behavior that extra service data attributes overwrite
|
||||
# profile values
|
||||
profile = profiles.get(dat.get(ATTR_PROFILE))
|
||||
# Processing extra data for turn light on request
|
||||
|
||||
if profile:
|
||||
color = profile[0:2]
|
||||
bright = profile[2]
|
||||
else:
|
||||
color = None
|
||||
bright = None
|
||||
# We process the profile first so that we get the desired
|
||||
# behavior that extra service data attributes overwrite
|
||||
# profile values
|
||||
profile = profiles.get(dat.get(ATTR_PROFILE))
|
||||
|
||||
if ATTR_BRIGHTNESS in dat:
|
||||
bright = util.convert(dat.get(ATTR_BRIGHTNESS), int)
|
||||
if profile:
|
||||
*params[ATTR_XY_COLOR], params[ATTR_BRIGHTNESS] = profile
|
||||
|
||||
if ATTR_XY_COLOR in dat:
|
||||
try:
|
||||
# xy_color should be a list containing 2 floats
|
||||
xy_color = [float(val) for val in dat.get(ATTR_XY_COLOR)]
|
||||
if ATTR_BRIGHTNESS in dat:
|
||||
# We pass in the old value as the default parameter if parsing
|
||||
# of the new one goes wrong.
|
||||
params[ATTR_BRIGHTNESS] = util.convert(
|
||||
dat.get(ATTR_BRIGHTNESS), int, params.get(ATTR_BRIGHTNESS))
|
||||
|
||||
if len(xy_color) == 2:
|
||||
color = xy_color
|
||||
if ATTR_XY_COLOR in dat:
|
||||
try:
|
||||
# xy_color should be a list containing 2 floats
|
||||
xycolor = dat.get(ATTR_XY_COLOR)
|
||||
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if dat[ATTR_XY_COLOR] is not iterable
|
||||
# ValueError if value could not be converted to float
|
||||
pass
|
||||
# Without this check, a xycolor with value '99' would work
|
||||
if not isinstance(xycolor, str):
|
||||
params[ATTR_XY_COLOR] = [float(val) for val in xycolor]
|
||||
|
||||
if ATTR_RGB_COLOR in dat:
|
||||
try:
|
||||
# rgb_color should be a list containing 3 ints
|
||||
rgb_color = [int(val) for val in dat.get(ATTR_RGB_COLOR)]
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if xy_color is not iterable
|
||||
# ValueError if value could not be converted to float
|
||||
pass
|
||||
|
||||
if len(rgb_color) == 3:
|
||||
color = util.color_RGB_to_xy(rgb_color[0],
|
||||
rgb_color[1],
|
||||
rgb_color[2])
|
||||
if ATTR_RGB_COLOR in dat:
|
||||
try:
|
||||
# rgb_color should be a list containing 3 ints
|
||||
rgb_color = dat.get(ATTR_RGB_COLOR)
|
||||
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if dat[ATTR_RGB_COLOR] is not iterable
|
||||
# ValueError if not all values can be converted to int
|
||||
pass
|
||||
if len(rgb_color) == 3:
|
||||
params[ATTR_XY_COLOR] = \
|
||||
color_util.color_RGB_to_xy(int(rgb_color[0]),
|
||||
int(rgb_color[1]),
|
||||
int(rgb_color[2]))
|
||||
|
||||
light_control.turn_light_on(light_ids, transition, bright, color)
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if rgb_color is not iterable
|
||||
# ValueError if not all values can be converted to int
|
||||
pass
|
||||
|
||||
# Update state of lights touched. If there was only 1 light selected
|
||||
# then just update that light else update all
|
||||
if len(light_ids) == 1:
|
||||
update_light_state(light_ids[0])
|
||||
else:
|
||||
update_lights_state(None, True)
|
||||
if ATTR_FLASH in dat:
|
||||
if dat[ATTR_FLASH] == FLASH_SHORT:
|
||||
params[ATTR_FLASH] = FLASH_SHORT
|
||||
|
||||
# Update light state every 30 seconds
|
||||
ha.track_time_change(bus, update_lights_state, second=[0, 30])
|
||||
elif dat[ATTR_FLASH] == FLASH_LONG:
|
||||
params[ATTR_FLASH] = FLASH_LONG
|
||||
|
||||
if ATTR_EFFECT in dat:
|
||||
if dat[ATTR_EFFECT] == EFFECT_COLORLOOP:
|
||||
params[ATTR_EFFECT] = EFFECT_COLORLOOP
|
||||
|
||||
for light in target_lights:
|
||||
light.turn_on(**params)
|
||||
|
||||
for light in target_lights:
|
||||
if light.should_poll:
|
||||
light.update_ha_state(True)
|
||||
|
||||
# Listen for light on and light off service calls
|
||||
bus.register_service(DOMAIN, SERVICE_TURN_ON,
|
||||
handle_light_service)
|
||||
hass.services.register(DOMAIN, SERVICE_TURN_ON,
|
||||
handle_light_service)
|
||||
|
||||
bus.register_service(DOMAIN, SERVICE_TURN_OFF,
|
||||
handle_light_service)
|
||||
hass.services.register(DOMAIN, SERVICE_TURN_OFF,
|
||||
handle_light_service)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
LightState = namedtuple("LightState", ['on', 'brightness', 'color'])
|
||||
class Light(ToggleEntity):
|
||||
""" Represents a light within Home Assistant. """
|
||||
# pylint: disable=no-self-use
|
||||
|
||||
|
||||
def _hue_to_light_state(info):
|
||||
""" Helper method to convert a Hue state to a LightState. """
|
||||
try:
|
||||
return LightState(info['state']['reachable'] and info['state']['on'],
|
||||
info['state']['bri'], info['state']['xy'])
|
||||
except KeyError:
|
||||
# KeyError if one of the keys didn't exist
|
||||
@property
|
||||
def brightness(self):
|
||||
""" Brightness of this light between 0..255. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def color_xy(self):
|
||||
""" XY color value [float, float]. """
|
||||
return None
|
||||
|
||||
class HueLightControl(object):
|
||||
""" Class to interface with the Hue light system. """
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
""" Returns device specific state attributes. """
|
||||
return None
|
||||
|
||||
def __init__(self, host=None):
|
||||
logger = logging.getLogger(__name__)
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Returns optional state attributes. """
|
||||
data = {}
|
||||
|
||||
try:
|
||||
import phue
|
||||
except ImportError:
|
||||
logger.exception(
|
||||
"HueLightControl:Error while importing dependency phue.")
|
||||
if self.is_on:
|
||||
for prop, attr in PROP_TO_ATTR.items():
|
||||
value = getattr(self, prop)
|
||||
if value:
|
||||
data[attr] = value
|
||||
|
||||
self.success_init = False
|
||||
device_attr = self.device_state_attributes
|
||||
|
||||
return
|
||||
if device_attr is not None:
|
||||
data.update(device_attr)
|
||||
|
||||
try:
|
||||
self._bridge = phue.Bridge(host)
|
||||
except socket.error: # Error connecting using Phue
|
||||
logger.exception((
|
||||
"HueLightControl:Error while connecting to the bridge. "
|
||||
"Is phue registered?"))
|
||||
|
||||
self.success_init = False
|
||||
|
||||
return
|
||||
|
||||
# Dict mapping light_id to name
|
||||
self._lights = {}
|
||||
self._update_lights()
|
||||
|
||||
if len(self._lights) == 0:
|
||||
logger.error("HueLightControl:Could not find any lights. ")
|
||||
|
||||
self.success_init = False
|
||||
else:
|
||||
self.success_init = True
|
||||
|
||||
def _update_lights(self):
|
||||
""" Helper method to update the known names from Hue. """
|
||||
try:
|
||||
self._lights = {int(item[0]): item[1]['name'] for item
|
||||
in self._bridge.get_light().items()}
|
||||
|
||||
except (socket.error, KeyError):
|
||||
# socket.error because sometimes we cannot reach Hue
|
||||
# KeyError if we got unexpected data
|
||||
# We don't do anything, keep old values
|
||||
pass
|
||||
|
||||
def get_name(self, light_id):
|
||||
""" Return name for specified light_id or None if no name known. """
|
||||
if not light_id in self._lights:
|
||||
self._update_lights()
|
||||
|
||||
return self._lights.get(light_id)
|
||||
|
||||
def get_state(self, light_id):
|
||||
""" Return a LightState representing light light_id. """
|
||||
try:
|
||||
info = self._bridge.get_light(light_id)
|
||||
|
||||
return _hue_to_light_state(info)
|
||||
|
||||
except socket.error:
|
||||
# socket.error when we cannot reach Hue
|
||||
return None
|
||||
|
||||
def get_states(self):
|
||||
""" Return a dict with id mapped to LightState objects. """
|
||||
states = {}
|
||||
|
||||
try:
|
||||
api = self._bridge.get_api()
|
||||
|
||||
except socket.error:
|
||||
# socket.error when we cannot reach Hue
|
||||
return states
|
||||
|
||||
api_states = api.get('lights')
|
||||
|
||||
if not isinstance(api_states, dict):
|
||||
return states
|
||||
|
||||
for light_id, info in api_states.items():
|
||||
state = _hue_to_light_state(info)
|
||||
|
||||
if state:
|
||||
states[int(light_id)] = state
|
||||
|
||||
return states
|
||||
|
||||
def turn_light_on(self, light_ids, transition, brightness, xy_color):
|
||||
""" Turn the specified or all lights on. """
|
||||
command = {'on': True}
|
||||
|
||||
if transition is not None:
|
||||
# Transition time is in 1/10th seconds and cannot exceed
|
||||
# 900 seconds.
|
||||
command['transitiontime'] = min(9000, transition * 10)
|
||||
|
||||
if brightness is not None:
|
||||
command['bri'] = brightness
|
||||
|
||||
if xy_color:
|
||||
command['xy'] = xy_color
|
||||
|
||||
self._bridge.set_light(light_ids, command)
|
||||
|
||||
def turn_light_off(self, light_ids, transition):
|
||||
""" Turn the specified or all lights off. """
|
||||
command = {'on': False}
|
||||
|
||||
if transition is not None:
|
||||
# Transition time is in 1/10th seconds and cannot exceed
|
||||
# 900 seconds.
|
||||
command['transitiontime'] = min(9000, transition * 10)
|
||||
|
||||
self._bridge.set_light(light_ids, command)
|
||||
return data
|
||||
|
||||
77
homeassistant/components/light/demo.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
homeassistant.components.light.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Demo platform that implements lights.
|
||||
|
||||
"""
|
||||
import random
|
||||
|
||||
from homeassistant.components.light import (
|
||||
Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR)
|
||||
|
||||
|
||||
LIGHT_COLORS = [
|
||||
[0.368, 0.180],
|
||||
[0.460, 0.470],
|
||||
]
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Find and return demo lights. """
|
||||
add_devices_callback([
|
||||
DemoLight("Bed Light", False),
|
||||
DemoLight("Ceiling Lights", True, LIGHT_COLORS[0]),
|
||||
DemoLight("Kitchen Lights", True, LIGHT_COLORS[1])
|
||||
])
|
||||
|
||||
|
||||
class DemoLight(Light):
|
||||
""" Provides a demo switch. """
|
||||
def __init__(self, name, state, xy=None, brightness=180):
|
||||
self._name = name
|
||||
self._state = state
|
||||
self._xy = xy or random.choice(LIGHT_COLORS)
|
||||
self._brightness = brightness
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" No polling needed for a demo light. """
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device if any. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
""" Brightness of this light between 0..255. """
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def color_xy(self):
|
||||
""" XY color value. """
|
||||
return self._xy
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if device is on. """
|
||||
return self._state
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turn the device on. """
|
||||
self._state = True
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
self._xy = kwargs[ATTR_XY_COLOR]
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
self.update_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turn the device off. """
|
||||
self._state = False
|
||||
self.update_ha_state()
|
||||
222
homeassistant/components/light/hue.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
homeassistant.components.light.hue
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for Hue lights.
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant.loader import get_component
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME
|
||||
from homeassistant.components.light import (
|
||||
Light, ATTR_BRIGHTNESS, ATTR_XY_COLOR, ATTR_TRANSITION,
|
||||
ATTR_FLASH, FLASH_LONG, FLASH_SHORT, ATTR_EFFECT,
|
||||
EFFECT_COLORLOOP)
|
||||
|
||||
REQUIREMENTS = ['phue==0.8']
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
|
||||
|
||||
PHUE_CONFIG_FILE = "phue.conf"
|
||||
|
||||
|
||||
# Map ip to request id for configuring
|
||||
_CONFIGURING = {}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Gets the Hue lights. """
|
||||
try:
|
||||
# pylint: disable=unused-variable
|
||||
import phue # noqa
|
||||
except ImportError:
|
||||
_LOGGER.exception("Error while importing dependency phue.")
|
||||
|
||||
return
|
||||
|
||||
if discovery_info is not None:
|
||||
host = urlparse(discovery_info[1]).hostname
|
||||
else:
|
||||
host = config.get(CONF_HOST, None)
|
||||
|
||||
# Only act if we are not already configuring this host
|
||||
if host in _CONFIGURING:
|
||||
return
|
||||
|
||||
setup_bridge(host, hass, add_devices_callback)
|
||||
|
||||
|
||||
def setup_bridge(host, hass, add_devices_callback):
|
||||
""" Setup a phue bridge based on host parameter. """
|
||||
import phue
|
||||
|
||||
try:
|
||||
bridge = phue.Bridge(
|
||||
host,
|
||||
config_file_path=hass.config.path(PHUE_CONFIG_FILE))
|
||||
except ConnectionRefusedError: # Wrong host was given
|
||||
_LOGGER.exception("Error connecting to the Hue bridge at %s", host)
|
||||
|
||||
return
|
||||
|
||||
except phue.PhueRegistrationException:
|
||||
_LOGGER.warning("Connected to Hue at %s but not registered.", host)
|
||||
|
||||
request_configuration(host, hass, add_devices_callback)
|
||||
|
||||
return
|
||||
|
||||
# If we came here and configuring this host, mark as done
|
||||
if host in _CONFIGURING:
|
||||
request_id = _CONFIGURING.pop(host)
|
||||
|
||||
configurator = get_component('configurator')
|
||||
|
||||
configurator.request_done(request_id)
|
||||
|
||||
lights = {}
|
||||
|
||||
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
||||
def update_lights():
|
||||
""" Updates the Hue light objects with latest info from the bridge. """
|
||||
try:
|
||||
api = bridge.get_api()
|
||||
except socket.error:
|
||||
# socket.error when we cannot reach Hue
|
||||
_LOGGER.exception("Cannot reach the bridge")
|
||||
return
|
||||
|
||||
api_states = api.get('lights')
|
||||
|
||||
if not isinstance(api_states, dict):
|
||||
_LOGGER.error("Got unexpected result from Hue API")
|
||||
return
|
||||
|
||||
new_lights = []
|
||||
|
||||
for light_id, info in api_states.items():
|
||||
if light_id not in lights:
|
||||
lights[light_id] = HueLight(int(light_id), info,
|
||||
bridge, update_lights)
|
||||
new_lights.append(lights[light_id])
|
||||
else:
|
||||
lights[light_id].info = info
|
||||
|
||||
if new_lights:
|
||||
add_devices_callback(new_lights)
|
||||
|
||||
update_lights()
|
||||
|
||||
|
||||
def request_configuration(host, hass, add_devices_callback):
|
||||
""" Request configuration steps from the user. """
|
||||
configurator = get_component('configurator')
|
||||
|
||||
# We got an error if this method is called while we are configuring
|
||||
if host in _CONFIGURING:
|
||||
configurator.notify_errors(
|
||||
_CONFIGURING[host], "Failed to register, please try again.")
|
||||
|
||||
return
|
||||
|
||||
def hue_configuration_callback(data):
|
||||
""" Actions to do when our configuration callback is called. """
|
||||
setup_bridge(host, hass, add_devices_callback)
|
||||
|
||||
_CONFIGURING[host] = configurator.request_config(
|
||||
hass, "Philips Hue", hue_configuration_callback,
|
||||
description=("Press the button on the bridge to register Philips Hue "
|
||||
"with Home Assistant."),
|
||||
description_image="/static/images/config_philips_hue.jpg",
|
||||
submit_caption="I have pressed the button"
|
||||
)
|
||||
|
||||
|
||||
class HueLight(Light):
|
||||
""" Represents a Hue light """
|
||||
|
||||
def __init__(self, light_id, info, bridge, update_lights):
|
||||
self.light_id = light_id
|
||||
self.info = info
|
||||
self.bridge = bridge
|
||||
self.update_lights = update_lights
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
""" Returns the id of this Hue light """
|
||||
return "{}.{}".format(
|
||||
self.__class__, self.info.get('uniqueid', self.name))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Get the mame of the Hue light. """
|
||||
return self.info.get('name', DEVICE_DEFAULT_NAME)
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
""" Brightness of this light between 0..255. """
|
||||
return self.info['state']['bri']
|
||||
|
||||
@property
|
||||
def color_xy(self):
|
||||
""" XY color value. """
|
||||
return self.info['state'].get('xy')
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if device is on. """
|
||||
self.update_lights()
|
||||
|
||||
return self.info['state']['reachable'] and self.info['state']['on']
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turn the specified or all lights on. """
|
||||
command = {'on': True}
|
||||
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
# Transition time is in 1/10th seconds and cannot exceed
|
||||
# 900 seconds.
|
||||
command['transitiontime'] = min(9000, kwargs[ATTR_TRANSITION] * 10)
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
command['bri'] = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
command['xy'] = kwargs[ATTR_XY_COLOR]
|
||||
|
||||
flash = kwargs.get(ATTR_FLASH)
|
||||
|
||||
if flash == FLASH_LONG:
|
||||
command['alert'] = 'lselect'
|
||||
elif flash == FLASH_SHORT:
|
||||
command['alert'] = 'select'
|
||||
else:
|
||||
command['alert'] = 'none'
|
||||
|
||||
effect = kwargs.get(ATTR_EFFECT)
|
||||
|
||||
if effect == EFFECT_COLORLOOP:
|
||||
command['effect'] = 'colorloop'
|
||||
else:
|
||||
command['effect'] = 'none'
|
||||
|
||||
self.bridge.set_light(self.light_id, command)
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turn the specified or all lights off. """
|
||||
command = {'on': False}
|
||||
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
# Transition time is in 1/10th seconds and cannot exceed
|
||||
# 900 seconds.
|
||||
command['transitiontime'] = min(9000, kwargs[ATTR_TRANSITION] * 10)
|
||||
|
||||
self.bridge.set_light(self.light_id, command)
|
||||
|
||||
def update(self):
|
||||
""" Synchronize state with bridge. """
|
||||
self.update_lights(no_throttle=True)
|
||||
46
homeassistant/components/light/isy994.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
homeassistant.components.light.isy994
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for ISY994 lights.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.isy994 import (ISYDeviceABC, ISY, SENSOR_STRING,
|
||||
HIDDEN_STRING)
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS
|
||||
from homeassistant.const import STATE_ON, STATE_OFF
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the ISY994 platform. """
|
||||
logger = logging.getLogger(__name__)
|
||||
devs = []
|
||||
# verify connection
|
||||
if ISY is None or not ISY.connected:
|
||||
logger.error('A connection has not been made to the ISY controller.')
|
||||
return False
|
||||
|
||||
# import dimmable nodes
|
||||
for (path, node) in ISY.nodes:
|
||||
if node.dimmable and SENSOR_STRING not in node.name:
|
||||
if HIDDEN_STRING in path:
|
||||
node.name += HIDDEN_STRING
|
||||
devs.append(ISYLightDevice(node))
|
||||
|
||||
add_devices(devs)
|
||||
|
||||
|
||||
class ISYLightDevice(ISYDeviceABC):
|
||||
""" Represents as ISY light. """
|
||||
|
||||
_domain = 'light'
|
||||
_dtype = 'analog'
|
||||
_attrs = {ATTR_BRIGHTNESS: 'value'}
|
||||
_onattrs = [ATTR_BRIGHTNESS]
|
||||
_states = [STATE_ON, STATE_OFF]
|
||||
|
||||
def _attr_filter(self, attr):
|
||||
""" Filter brightness out of entity while off. """
|
||||
if ATTR_BRIGHTNESS in attr and not self.is_on:
|
||||
del attr[ATTR_BRIGHTNESS]
|
||||
return attr
|
||||
143
homeassistant/components/light/limitlessled.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
homeassistant.components.light.limitlessled
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Support for LimitlessLED bulbs, also known as...
|
||||
|
||||
- EasyBulb
|
||||
- AppLight
|
||||
- AppLamp
|
||||
- MiLight
|
||||
- LEDme
|
||||
- dekolight
|
||||
- iLight
|
||||
|
||||
Configuration:
|
||||
|
||||
To use limitlessled you will need to add the following to your
|
||||
configuration.yaml file.
|
||||
|
||||
light:
|
||||
platform: limitlessled
|
||||
host: 192.168.1.10
|
||||
group_1_name: Living Room
|
||||
group_2_name: Bedroom
|
||||
group_3_name: Office
|
||||
group_4_name: Kitchen
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import DEVICE_DEFAULT_NAME
|
||||
from homeassistant.components.light import (Light, ATTR_BRIGHTNESS,
|
||||
ATTR_XY_COLOR)
|
||||
from homeassistant.util.color import color_RGB_to_xy
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['ledcontroller==1.0.7']
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Gets the LimitlessLED lights. """
|
||||
import ledcontroller
|
||||
|
||||
led = ledcontroller.LedController(config['host'])
|
||||
|
||||
lights = []
|
||||
for i in range(1, 5):
|
||||
if 'group_%d_name' % (i) in config:
|
||||
lights.append(LimitlessLED(led, i, config['group_%d_name' % (i)]))
|
||||
|
||||
add_devices_callback(lights)
|
||||
|
||||
|
||||
class LimitlessLED(Light):
|
||||
""" Represents a LimitlessLED light """
|
||||
|
||||
def __init__(self, led, group, name):
|
||||
self.led = led
|
||||
self.group = group
|
||||
|
||||
# LimitlessLEDs don't report state, we have track it ourselves.
|
||||
self.led.off(self.group)
|
||||
|
||||
self._name = name or DEVICE_DEFAULT_NAME
|
||||
self._state = False
|
||||
self._brightness = 100
|
||||
self._xy_color = color_RGB_to_xy(255, 255, 255)
|
||||
|
||||
# Build a color table that maps an RGB color to a color string
|
||||
# recognized by LedController's set_color method
|
||||
self._color_table = [(color_RGB_to_xy(*x[0]), x[1]) for x in [
|
||||
((0xFF, 0xFF, 0xFF), 'white'),
|
||||
((0xEE, 0x82, 0xEE), 'violet'),
|
||||
((0x41, 0x69, 0xE1), 'royal_blue'),
|
||||
((0x87, 0xCE, 0xFA), 'baby_blue'),
|
||||
((0x00, 0xFF, 0xFF), 'aqua'),
|
||||
((0x7F, 0xFF, 0xD4), 'royal_mint'),
|
||||
((0x2E, 0x8B, 0x57), 'seafoam_green'),
|
||||
((0x00, 0x80, 0x00), 'green'),
|
||||
((0x32, 0xCD, 0x32), 'lime_green'),
|
||||
((0xFF, 0xFF, 0x00), 'yellow'),
|
||||
((0xDA, 0xA5, 0x20), 'yellow_orange'),
|
||||
((0xFF, 0xA5, 0x00), 'orange'),
|
||||
((0xFF, 0x00, 0x00), 'red'),
|
||||
((0xFF, 0xC0, 0xCB), 'pink'),
|
||||
((0xFF, 0x00, 0xFF), 'fusia'),
|
||||
((0xDA, 0x70, 0xD6), 'lilac'),
|
||||
((0xE6, 0xE6, 0xFA), 'lavendar'),
|
||||
]]
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" No polling needed for a demo light. """
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device if any. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def color_xy(self):
|
||||
return self._xy_color
|
||||
|
||||
def _xy_to_led_color(self, xy_color):
|
||||
""" Convert an XY color to the closest LedController color string. """
|
||||
def abs_dist_squared(p_0, p_1):
|
||||
""" Returns the absolute value of the squared distance """
|
||||
return abs((p_0[0] - p_1[0])**2 + (p_0[1] - p_1[1])**2)
|
||||
|
||||
candidates = [(abs_dist_squared(xy_color, x[0]), x[1]) for x in
|
||||
self._color_table]
|
||||
|
||||
# First candidate in the sorted list is closest to desired color:
|
||||
return sorted(candidates)[0][1]
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if device is on. """
|
||||
return self._state
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turn the device on. """
|
||||
self._state = True
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
self._xy_color = kwargs[ATTR_XY_COLOR]
|
||||
|
||||
self.led.set_color(self._xy_to_led_color(self._xy_color), self.group)
|
||||
self.led.set_brightness(self._brightness / 255.0, self.group)
|
||||
self.update_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turn the device off. """
|
||||
self._state = False
|
||||
self.led.off(self.group)
|
||||
self.update_ha_state()
|
||||
93
homeassistant/components/light/tellstick.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
homeassistant.components.light.tellstick
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for Tellstick lights.
|
||||
"""
|
||||
import logging
|
||||
# pylint: disable=no-name-in-module, import-error
|
||||
from homeassistant.components.light import Light, ATTR_BRIGHTNESS
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
import tellcore.constants as tellcore_constants
|
||||
|
||||
REQUIREMENTS = ['tellcore-py==1.0.4']
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Find and return Tellstick lights. """
|
||||
|
||||
try:
|
||||
import tellcore.telldus as telldus
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).exception(
|
||||
"Failed to import tellcore")
|
||||
return []
|
||||
|
||||
core = telldus.TelldusCore()
|
||||
switches_and_lights = core.devices()
|
||||
lights = []
|
||||
|
||||
for switch in switches_and_lights:
|
||||
if switch.methods(tellcore_constants.TELLSTICK_DIM):
|
||||
lights.append(TellstickLight(switch))
|
||||
add_devices_callback(lights)
|
||||
|
||||
|
||||
class TellstickLight(Light):
|
||||
""" Represents a Tellstick light. """
|
||||
last_sent_command_mask = (tellcore_constants.TELLSTICK_TURNON |
|
||||
tellcore_constants.TELLSTICK_TURNOFF |
|
||||
tellcore_constants.TELLSTICK_DIM |
|
||||
tellcore_constants.TELLSTICK_UP |
|
||||
tellcore_constants.TELLSTICK_DOWN)
|
||||
|
||||
def __init__(self, tellstick):
|
||||
self.tellstick = tellstick
|
||||
self.state_attr = {ATTR_FRIENDLY_NAME: tellstick.name}
|
||||
self._brightness = 0
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the switch if any. """
|
||||
return self.tellstick.name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
""" True if switch is on. """
|
||||
return self._brightness > 0
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
""" Brightness of this light between 0..255. """
|
||||
return self._brightness
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
""" Turns the switch off. """
|
||||
self.tellstick.turn_off()
|
||||
self._brightness = 0
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turns the switch on. """
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
|
||||
if brightness is None:
|
||||
self._brightness = 255
|
||||
else:
|
||||
self._brightness = brightness
|
||||
|
||||
self.tellstick.dim(self._brightness)
|
||||
|
||||
def update(self):
|
||||
""" Update state of the light. """
|
||||
last_command = self.tellstick.last_sent_command(
|
||||
self.last_sent_command_mask)
|
||||
|
||||
if last_command == tellcore_constants.TELLSTICK_TURNON:
|
||||
self._brightness = 255
|
||||
elif last_command == tellcore_constants.TELLSTICK_TURNOFF:
|
||||
self._brightness = 0
|
||||
elif (last_command == tellcore_constants.TELLSTICK_DIM or
|
||||
last_command == tellcore_constants.TELLSTICK_UP or
|
||||
last_command == tellcore_constants.TELLSTICK_DOWN):
|
||||
last_sent_value = self.tellstick.last_sent_value()
|
||||
if last_sent_value is not None:
|
||||
self._brightness = last_sent_value
|
||||
94
homeassistant/components/light/vera.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
homeassistant.components.light.vera
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for Vera lights. This component is useful if you wish for switches
|
||||
connected to your Vera controller to appear as lights in Home Assistant.
|
||||
All switches will be added as a light unless you exclude them in the config.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Vera lights you will need to add something like the following to
|
||||
your configuration.yaml file.
|
||||
|
||||
light:
|
||||
platform: vera
|
||||
vera_controller_url: http://YOUR_VERA_IP:3480/
|
||||
device_data:
|
||||
12:
|
||||
name: My awesome switch
|
||||
exclude: true
|
||||
13:
|
||||
name: Another switch
|
||||
|
||||
Variables:
|
||||
|
||||
vera_controller_url
|
||||
*Required
|
||||
This is the base URL of your vera controller including the port number if not
|
||||
running on 80. Example: http://192.168.1.21:3480/
|
||||
|
||||
device_data
|
||||
*Optional
|
||||
This contains an array additional device info for your Vera devices. It is not
|
||||
required and if not specified all lights configured in your Vera controller
|
||||
will be added with default values. You should use the id of your vera device
|
||||
as the key for the device within device_data.
|
||||
|
||||
These are the variables for the device_data array:
|
||||
|
||||
name
|
||||
*Optional
|
||||
This parameter allows you to override the name of your Vera device in the HA
|
||||
interface, if not specified the value configured for the device in your Vera
|
||||
will be used.
|
||||
|
||||
exclude
|
||||
*Optional
|
||||
This parameter allows you to exclude the specified device from Home Assistant,
|
||||
it should be set to "true" if you want this device excluded.
|
||||
|
||||
"""
|
||||
import logging
|
||||
from requests.exceptions import RequestException
|
||||
from homeassistant.components.switch.vera import VeraSwitch
|
||||
|
||||
REQUIREMENTS = ['https://github.com/balloob/home-assistant-vera-api/archive/'
|
||||
'a8f823066ead6c7da6fb5e7abaf16fef62e63364.zip'
|
||||
'#python-vera==0.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Find and return Vera lights. """
|
||||
import pyvera as veraApi
|
||||
|
||||
base_url = config.get('vera_controller_url')
|
||||
if not base_url:
|
||||
_LOGGER.error(
|
||||
"The required parameter 'vera_controller_url'"
|
||||
" was not found in config"
|
||||
)
|
||||
return False
|
||||
|
||||
device_data = config.get('device_data', {})
|
||||
|
||||
controller = veraApi.VeraController(base_url)
|
||||
devices = []
|
||||
try:
|
||||
devices = controller.get_devices(['Switch', 'On/Off Switch'])
|
||||
except RequestException:
|
||||
# There was a network related error connecting to the vera controller
|
||||
_LOGGER.exception("Error communicating with Vera API")
|
||||
return False
|
||||
|
||||
lights = []
|
||||
for device in devices:
|
||||
extra_data = device_data.get(device.deviceId, {})
|
||||
exclude = extra_data.get('exclude', False)
|
||||
|
||||
if exclude is not True:
|
||||
lights.append(VeraSwitch(device, extra_data))
|
||||
|
||||
add_devices_callback(lights)
|
||||
60
homeassistant/components/light/wink.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
homeassistant.components.light.wink
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Support for Wink lights.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS
|
||||
from homeassistant.components.wink import WinkToggleDevice
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
|
||||
REQUIREMENTS = ['https://github.com/balloob/python-wink/archive/'
|
||||
'c2b700e8ca866159566ecf5e644d9c297f69f257.zip'
|
||||
'#python-wink==0.1']
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
""" Find and return Wink lights. """
|
||||
import pywink
|
||||
|
||||
token = config.get(CONF_ACCESS_TOKEN)
|
||||
|
||||
if not pywink.is_token_set() and token is None:
|
||||
logging.getLogger(__name__).error(
|
||||
"Missing wink access_token - "
|
||||
"get one at https://winkbearertoken.appspot.com/")
|
||||
return
|
||||
|
||||
elif token is not None:
|
||||
pywink.set_bearer_token(token)
|
||||
|
||||
add_devices_callback(
|
||||
WinkLight(light) for light in pywink.get_bulbs())
|
||||
|
||||
|
||||
class WinkLight(WinkToggleDevice):
|
||||
""" Represents a Wink light. """
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
def turn_on(self, **kwargs):
|
||||
""" Turns the switch on. """
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
|
||||
if brightness is not None:
|
||||
self.wink.setState(True, brightness / 255)
|
||||
|
||||
else:
|
||||
self.wink.setState(True)
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
attr = super().state_attributes
|
||||
|
||||
if self.is_on:
|
||||
brightness = self.wink.brightness()
|
||||
|
||||
if brightness is not None:
|
||||
attr[ATTR_BRIGHTNESS] = int(brightness * 255)
|
||||
|
||||
return attr
|
||||
200
homeassistant/components/logbook.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
homeassistant.components.logbook
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Parses events and generates a human log.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from itertools import groupby
|
||||
import re
|
||||
|
||||
from homeassistant.core import State, DOMAIN as HA_DOMAIN
|
||||
from homeassistant.const import (
|
||||
EVENT_STATE_CHANGED, STATE_HOME, STATE_ON, STATE_OFF,
|
||||
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST)
|
||||
import homeassistant.util.dt as dt_util
|
||||
import homeassistant.components.recorder as recorder
|
||||
import homeassistant.components.sun as sun
|
||||
|
||||
DOMAIN = "logbook"
|
||||
DEPENDENCIES = ['recorder', 'http']
|
||||
|
||||
URL_LOGBOOK = re.compile(r'/api/logbook(?:/(?P<date>\d{4}-\d{1,2}-\d{1,2})|)')
|
||||
|
||||
QUERY_EVENTS_BETWEEN = """
|
||||
SELECT * FROM events WHERE time_fired > ? AND time_fired < ?
|
||||
"""
|
||||
|
||||
GROUP_BY_MINUTES = 15
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Listens for download events to download files. """
|
||||
hass.http.register_path('GET', URL_LOGBOOK, _handle_get_logbook)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_logbook(handler, path_match, data):
|
||||
""" Return logbook entries. """
|
||||
date_str = path_match.group('date')
|
||||
|
||||
if date_str:
|
||||
start_date = dt_util.date_str_to_date(date_str)
|
||||
|
||||
if start_date is None:
|
||||
handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST)
|
||||
return
|
||||
|
||||
start_day = dt_util.start_of_local_day(start_date)
|
||||
else:
|
||||
start_day = dt_util.start_of_local_day()
|
||||
|
||||
end_day = start_day + timedelta(days=1)
|
||||
|
||||
events = recorder.query_events(
|
||||
QUERY_EVENTS_BETWEEN,
|
||||
(dt_util.as_utc(start_day), dt_util.as_utc(end_day)))
|
||||
|
||||
handler.write_json(humanify(events))
|
||||
|
||||
|
||||
class Entry(object):
|
||||
""" A human readable version of the log. """
|
||||
|
||||
# pylint: disable=too-many-arguments, too-few-public-methods
|
||||
|
||||
def __init__(self, when=None, name=None, message=None, domain=None,
|
||||
entity_id=None):
|
||||
self.when = when
|
||||
self.name = name
|
||||
self.message = message
|
||||
self.domain = domain
|
||||
self.entity_id = entity_id
|
||||
|
||||
def as_dict(self):
|
||||
""" Convert Entry to a dict to be used within JSON. """
|
||||
return {
|
||||
'when': dt_util.datetime_to_str(self.when),
|
||||
'name': self.name,
|
||||
'message': self.message,
|
||||
'domain': self.domain,
|
||||
'entity_id': self.entity_id,
|
||||
}
|
||||
|
||||
|
||||
def humanify(events):
|
||||
"""
|
||||
Generator that converts a list of events into Entry objects.
|
||||
|
||||
Will try to group events if possible:
|
||||
- if 2+ sensor updates in GROUP_BY_MINUTES, show last
|
||||
- if home assistant stop and start happen in same minute call it restarted
|
||||
"""
|
||||
# pylint: disable=too-many-branches
|
||||
|
||||
# Group events in batches of GROUP_BY_MINUTES
|
||||
for _, g_events in groupby(
|
||||
events,
|
||||
lambda event: event.time_fired.minute // GROUP_BY_MINUTES):
|
||||
|
||||
events_batch = list(g_events)
|
||||
|
||||
# Keep track of last sensor states
|
||||
last_sensor_event = {}
|
||||
|
||||
# group HA start/stop events
|
||||
# Maps minute of event to 1: stop, 2: stop + start
|
||||
start_stop_events = {}
|
||||
|
||||
# Process events
|
||||
for event in events_batch:
|
||||
if event.event_type == EVENT_STATE_CHANGED:
|
||||
entity_id = event.data['entity_id']
|
||||
|
||||
if entity_id.startswith('sensor.'):
|
||||
last_sensor_event[entity_id] = event
|
||||
|
||||
elif event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
if event.time_fired.minute in start_stop_events:
|
||||
continue
|
||||
|
||||
start_stop_events[event.time_fired.minute] = 1
|
||||
|
||||
elif event.event_type == EVENT_HOMEASSISTANT_START:
|
||||
if event.time_fired.minute not in start_stop_events:
|
||||
continue
|
||||
|
||||
start_stop_events[event.time_fired.minute] = 2
|
||||
|
||||
# Yield entries
|
||||
for event in events_batch:
|
||||
if event.event_type == EVENT_STATE_CHANGED:
|
||||
|
||||
# Do not report on new entities
|
||||
if 'old_state' not in event.data:
|
||||
continue
|
||||
|
||||
to_state = State.from_dict(event.data.get('new_state'))
|
||||
|
||||
# if last_changed == last_updated only attributes have changed
|
||||
# we do not report on that yet.
|
||||
if not to_state or \
|
||||
to_state.last_changed != to_state.last_updated:
|
||||
continue
|
||||
|
||||
domain = to_state.domain
|
||||
|
||||
# Skip all but the last sensor state
|
||||
if domain == 'sensor' and \
|
||||
event != last_sensor_event[to_state.entity_id]:
|
||||
continue
|
||||
|
||||
yield Entry(
|
||||
event.time_fired,
|
||||
name=to_state.name,
|
||||
message=_entry_message_from_state(domain, to_state),
|
||||
domain=domain,
|
||||
entity_id=to_state.entity_id)
|
||||
|
||||
elif event.event_type == EVENT_HOMEASSISTANT_START:
|
||||
if start_stop_events.get(event.time_fired.minute) == 2:
|
||||
continue
|
||||
|
||||
yield Entry(
|
||||
event.time_fired, "Home Assistant", "started",
|
||||
domain=HA_DOMAIN)
|
||||
|
||||
elif event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
if start_stop_events.get(event.time_fired.minute) == 2:
|
||||
action = "restarted"
|
||||
else:
|
||||
action = "stopped"
|
||||
|
||||
yield Entry(
|
||||
event.time_fired, "Home Assistant", action,
|
||||
domain=HA_DOMAIN)
|
||||
|
||||
|
||||
def _entry_message_from_state(domain, state):
|
||||
""" Convert a state to a message for the logbook. """
|
||||
# We pass domain in so we don't have to split entity_id again
|
||||
|
||||
if domain == 'device_tracker':
|
||||
return '{} home'.format(
|
||||
'arrived' if state.state == STATE_HOME else 'left')
|
||||
|
||||
elif domain == 'sun':
|
||||
if state.state == sun.STATE_ABOVE_HORIZON:
|
||||
return 'has risen'
|
||||
else:
|
||||
return 'has set'
|
||||
|
||||
elif state.state == STATE_ON:
|
||||
# Future: combine groups and its entity entries ?
|
||||
return "turned on"
|
||||
|
||||
elif state.state == STATE_OFF:
|
||||
return "turned off"
|
||||
|
||||
return "changed to {}".format(state.state)
|
||||
497
homeassistant/components/media_player/__init__.py
Normal file
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
homeassistant.components.media_player
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Component to interface with various media players.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components import discovery
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.const import (
|
||||
STATE_OFF, STATE_UNKNOWN, STATE_PLAYING,
|
||||
ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK)
|
||||
|
||||
DOMAIN = 'media_player'
|
||||
DEPENDENCIES = []
|
||||
SCAN_INTERVAL = 30
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
DISCOVERY_PLATFORMS = {
|
||||
discovery.SERVICE_CAST: 'cast',
|
||||
}
|
||||
|
||||
SERVICE_YOUTUBE_VIDEO = 'play_youtube_video'
|
||||
|
||||
ATTR_MEDIA_VOLUME_LEVEL = 'volume_level'
|
||||
ATTR_MEDIA_VOLUME_MUTED = 'is_volume_muted'
|
||||
ATTR_MEDIA_SEEK_POSITION = 'seek_position'
|
||||
ATTR_MEDIA_CONTENT_ID = 'media_content_id'
|
||||
ATTR_MEDIA_CONTENT_TYPE = 'media_content_type'
|
||||
ATTR_MEDIA_DURATION = 'media_duration'
|
||||
ATTR_MEDIA_TITLE = 'media_title'
|
||||
ATTR_MEDIA_ARTIST = 'media_artist'
|
||||
ATTR_MEDIA_ALBUM_NAME = 'media_album_name'
|
||||
ATTR_MEDIA_ALBUM_ARTIST = 'media_album_artist'
|
||||
ATTR_MEDIA_TRACK = 'media_track'
|
||||
ATTR_MEDIA_SERIES_TITLE = 'media_series_title'
|
||||
ATTR_MEDIA_SEASON = 'media_season'
|
||||
ATTR_MEDIA_EPISODE = 'media_episode'
|
||||
ATTR_APP_ID = 'app_id'
|
||||
ATTR_APP_NAME = 'app_name'
|
||||
ATTR_SUPPORTED_MEDIA_COMMANDS = 'supported_media_commands'
|
||||
|
||||
MEDIA_TYPE_MUSIC = 'music'
|
||||
MEDIA_TYPE_TVSHOW = 'tvshow'
|
||||
MEDIA_TYPE_VIDEO = 'movie'
|
||||
|
||||
SUPPORT_PAUSE = 1
|
||||
SUPPORT_SEEK = 2
|
||||
SUPPORT_VOLUME_SET = 4
|
||||
SUPPORT_VOLUME_MUTE = 8
|
||||
SUPPORT_PREVIOUS_TRACK = 16
|
||||
SUPPORT_NEXT_TRACK = 32
|
||||
SUPPORT_YOUTUBE = 64
|
||||
SUPPORT_TURN_ON = 128
|
||||
SUPPORT_TURN_OFF = 256
|
||||
|
||||
YOUTUBE_COVER_URL_FORMAT = 'https://img.youtube.com/vi/{}/1.jpg'
|
||||
|
||||
SERVICE_TO_METHOD = {
|
||||
SERVICE_TURN_ON: 'turn_on',
|
||||
SERVICE_TURN_OFF: 'turn_off',
|
||||
SERVICE_VOLUME_UP: 'volume_up',
|
||||
SERVICE_VOLUME_DOWN: 'volume_down',
|
||||
SERVICE_MEDIA_PLAY_PAUSE: 'media_play_pause',
|
||||
SERVICE_MEDIA_PLAY: 'media_play',
|
||||
SERVICE_MEDIA_PAUSE: 'media_pause',
|
||||
SERVICE_MEDIA_NEXT_TRACK: 'media_next_track',
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK: 'media_previous_track',
|
||||
}
|
||||
|
||||
ATTR_TO_PROPERTY = [
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
ATTR_MEDIA_DURATION,
|
||||
ATTR_MEDIA_TITLE,
|
||||
ATTR_MEDIA_ARTIST,
|
||||
ATTR_MEDIA_ALBUM_NAME,
|
||||
ATTR_MEDIA_ALBUM_ARTIST,
|
||||
ATTR_MEDIA_TRACK,
|
||||
ATTR_MEDIA_SERIES_TITLE,
|
||||
ATTR_MEDIA_SEASON,
|
||||
ATTR_MEDIA_EPISODE,
|
||||
ATTR_APP_ID,
|
||||
ATTR_APP_NAME,
|
||||
ATTR_SUPPORTED_MEDIA_COMMANDS,
|
||||
]
|
||||
|
||||
|
||||
def is_on(hass, entity_id=None):
|
||||
""" Returns true if specified media player entity_id is on.
|
||||
Will check all media player if no entity_id specified. """
|
||||
entity_ids = [entity_id] if entity_id else hass.states.entity_ids(DOMAIN)
|
||||
return any(not hass.states.is_state(entity_id, STATE_OFF)
|
||||
for entity_id in entity_ids)
|
||||
|
||||
|
||||
def turn_on(hass, entity_id=None):
|
||||
""" Will turn on specified media player or all. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
|
||||
|
||||
|
||||
def turn_off(hass, entity_id=None):
|
||||
""" Will turn off specified media player or all. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_TURN_OFF, data)
|
||||
|
||||
|
||||
def volume_up(hass, entity_id=None):
|
||||
""" Send the media player the command for volume up. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_UP, data)
|
||||
|
||||
|
||||
def volume_down(hass, entity_id=None):
|
||||
""" Send the media player the command for volume down. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN, data)
|
||||
|
||||
|
||||
def mute_volume(hass, mute, entity_id=None):
|
||||
""" Send the media player the command for volume down. """
|
||||
data = {ATTR_MEDIA_VOLUME_MUTED: mute}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, data)
|
||||
|
||||
|
||||
def set_volume_level(hass, volume, entity_id=None):
|
||||
""" Send the media player the command for volume down. """
|
||||
data = {ATTR_MEDIA_VOLUME_LEVEL: volume}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_VOLUME_SET, data)
|
||||
|
||||
|
||||
def media_play_pause(hass, entity_id=None):
|
||||
""" Send the media player the command for play/pause. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data)
|
||||
|
||||
|
||||
def media_play(hass, entity_id=None):
|
||||
""" Send the media player the command for play/pause. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY, data)
|
||||
|
||||
|
||||
def media_pause(hass, entity_id=None):
|
||||
""" Send the media player the command for play/pause. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_PAUSE, data)
|
||||
|
||||
|
||||
def media_next_track(hass, entity_id=None):
|
||||
""" Send the media player the command for next track. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data)
|
||||
|
||||
|
||||
def media_previous_track(hass, entity_id=None):
|
||||
""" Send the media player the command for prev track. """
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Track states and offer events for media_players. """
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL,
|
||||
DISCOVERY_PLATFORMS)
|
||||
|
||||
component.setup(config)
|
||||
|
||||
def media_player_service_handler(service):
|
||||
""" Maps services to methods on MediaPlayerDevice. """
|
||||
target_players = component.extract_from_service(service)
|
||||
|
||||
method = SERVICE_TO_METHOD[service.service]
|
||||
|
||||
for player in target_players:
|
||||
getattr(player, method)()
|
||||
|
||||
if player.should_poll:
|
||||
player.update_ha_state(True)
|
||||
|
||||
for service in SERVICE_TO_METHOD:
|
||||
hass.services.register(DOMAIN, service, media_player_service_handler)
|
||||
|
||||
def volume_set_service(service):
|
||||
""" Set specified volume on the media player. """
|
||||
target_players = component.extract_from_service(service)
|
||||
|
||||
if ATTR_MEDIA_VOLUME_LEVEL not in service.data:
|
||||
return
|
||||
|
||||
volume = service.data[ATTR_MEDIA_VOLUME_LEVEL]
|
||||
|
||||
for player in target_players:
|
||||
player.set_volume_level(volume)
|
||||
|
||||
if player.should_poll:
|
||||
player.update_ha_state(True)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_VOLUME_SET, volume_set_service)
|
||||
|
||||
def volume_mute_service(service):
|
||||
""" Mute (true) or unmute (false) the media player. """
|
||||
target_players = component.extract_from_service(service)
|
||||
|
||||
if ATTR_MEDIA_VOLUME_MUTED not in service.data:
|
||||
return
|
||||
|
||||
mute = service.data[ATTR_MEDIA_VOLUME_MUTED]
|
||||
|
||||
for player in target_players:
|
||||
player.mute_volume(mute)
|
||||
|
||||
if player.should_poll:
|
||||
player.update_ha_state(True)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE, volume_mute_service)
|
||||
|
||||
def media_seek_service(service):
|
||||
""" Seek to a position. """
|
||||
target_players = component.extract_from_service(service)
|
||||
|
||||
if ATTR_MEDIA_SEEK_POSITION not in service.data:
|
||||
return
|
||||
|
||||
position = service.data[ATTR_MEDIA_SEEK_POSITION]
|
||||
|
||||
for player in target_players:
|
||||
player.seek(position)
|
||||
|
||||
if player.should_poll:
|
||||
player.update_ha_state(True)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_MEDIA_SEEK, media_seek_service)
|
||||
|
||||
def play_youtube_video_service(service, media_id=None):
|
||||
""" Plays specified media_id on the media player. """
|
||||
if media_id is None:
|
||||
service.data.get('video')
|
||||
|
||||
if media_id is None:
|
||||
return
|
||||
|
||||
for player in component.extract_from_service(service):
|
||||
player.play_youtube(media_id)
|
||||
|
||||
if player.should_poll:
|
||||
player.update_ha_state(True)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, "start_fireplace",
|
||||
lambda service: play_youtube_video_service(service, "eyU3bRy2x44"))
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, "start_epic_sax",
|
||||
lambda service: play_youtube_video_service(service, "kxopViU98Xo"))
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_YOUTUBE_VIDEO, play_youtube_video_service)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class MediaPlayerDevice(Entity):
|
||||
""" ABC for media player devices. """
|
||||
# pylint: disable=too-many-public-methods,no-self-use
|
||||
|
||||
# Implement these for your media player
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" State of the player. """
|
||||
return STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
""" Volume level of the media player (0..1). """
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
""" Boolean if volume is currently muted. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
""" Content ID of current playing media. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
""" Content type of current playing media. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
""" Duration of current playing media in seconds. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
""" Image url of current playing media. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Title of current playing media. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
""" Artist of current playing media. (Music track only) """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
""" Album name of current playing media. (Music track only) """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_album_artist(self):
|
||||
""" Album arist of current playing media. (Music track only) """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_track(self):
|
||||
""" Track number of current playing media. (Music track only) """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_series_title(self):
|
||||
""" Series title of current playing media. (TV Show only)"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_season(self):
|
||||
""" Season of current playing media. (TV Show only) """
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_episode(self):
|
||||
""" Episode of current playing media. (TV Show only) """
|
||||
return None
|
||||
|
||||
@property
|
||||
def app_id(self):
|
||||
""" ID of the current running app. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
""" Name of the current running app. """
|
||||
return None
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
return 0
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
""" Extra attributes a device wants to expose. """
|
||||
return None
|
||||
|
||||
def turn_on(self):
|
||||
""" turn the media player on. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def turn_off(self):
|
||||
""" turn the media player off. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def mute_volume(self, mute):
|
||||
""" mute the volume. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
""" set volume level, range 0..1. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def media_play(self):
|
||||
""" Send play commmand. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def media_pause(self):
|
||||
""" Send pause command. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def media_previous_track(self):
|
||||
""" Send previous track command. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def media_next_track(self):
|
||||
""" Send next track command. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def media_seek(self, position):
|
||||
""" Send seek command. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def play_youtube(self, media_id):
|
||||
""" Plays a YouTube media. """
|
||||
raise NotImplementedError()
|
||||
|
||||
# No need to overwrite these.
|
||||
@property
|
||||
def support_pause(self):
|
||||
""" Boolean if pause is supported. """
|
||||
return bool(self.supported_media_commands & SUPPORT_PAUSE)
|
||||
|
||||
@property
|
||||
def support_seek(self):
|
||||
""" Boolean if seek is supported. """
|
||||
return bool(self.supported_media_commands & SUPPORT_SEEK)
|
||||
|
||||
@property
|
||||
def support_volume_set(self):
|
||||
""" Boolean if setting volume is supported. """
|
||||
return bool(self.supported_media_commands & SUPPORT_VOLUME_SET)
|
||||
|
||||
@property
|
||||
def support_volume_mute(self):
|
||||
""" Boolean if muting volume is supported. """
|
||||
return bool(self.supported_media_commands & SUPPORT_VOLUME_MUTE)
|
||||
|
||||
@property
|
||||
def support_previous_track(self):
|
||||
""" Boolean if previous track command supported. """
|
||||
return bool(self.supported_media_commands & SUPPORT_PREVIOUS_TRACK)
|
||||
|
||||
@property
|
||||
def support_next_track(self):
|
||||
""" Boolean if next track command supported. """
|
||||
return bool(self.supported_media_commands & SUPPORT_NEXT_TRACK)
|
||||
|
||||
@property
|
||||
def support_youtube(self):
|
||||
""" Boolean if YouTube is supported. """
|
||||
return bool(self.supported_media_commands & SUPPORT_YOUTUBE)
|
||||
|
||||
def volume_up(self):
|
||||
""" volume_up media player. """
|
||||
if self.volume_level < 1:
|
||||
self.set_volume_level(min(1, self.volume_level + .1))
|
||||
|
||||
def volume_down(self):
|
||||
""" volume_down media player. """
|
||||
if self.volume_level > 0:
|
||||
self.set_volume_level(max(0, self.volume_level - .1))
|
||||
|
||||
def media_play_pause(self):
|
||||
""" media_play_pause media player. """
|
||||
if self.state == STATE_PLAYING:
|
||||
self.media_pause()
|
||||
else:
|
||||
self.media_play()
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
""" Return the state attributes. """
|
||||
if self.state == STATE_OFF:
|
||||
state_attr = {
|
||||
ATTR_SUPPORTED_MEDIA_COMMANDS: self.supported_media_commands,
|
||||
}
|
||||
else:
|
||||
state_attr = {
|
||||
attr: getattr(self, attr) for attr
|
||||
in ATTR_TO_PROPERTY if getattr(self, attr)
|
||||
}
|
||||
|
||||
if self.media_image_url:
|
||||
state_attr[ATTR_ENTITY_PICTURE] = self.media_image_url
|
||||
|
||||
device_attr = self.device_state_attributes
|
||||
|
||||
if device_attr:
|
||||
state_attr.update(device_attr)
|
||||
|
||||
return state_attr
|
||||
291
homeassistant/components/media_player/cast.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
homeassistant.components.media_player.chromecast
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides functionality to interact with Cast devices on the network.
|
||||
|
||||
WARNING: This platform is currently not working due to a changed Cast API.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the chromecast integration you will need to add something like the
|
||||
following to your configuration.yaml file.
|
||||
|
||||
media_player:
|
||||
platform: chromecast
|
||||
host: 192.168.1.9
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Optional
|
||||
Use only if you don't want to scan for devices.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import (
|
||||
STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF,
|
||||
STATE_UNKNOWN, CONF_HOST)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDevice,
|
||||
SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE,
|
||||
SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_YOUTUBE,
|
||||
SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK,
|
||||
MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO)
|
||||
|
||||
REQUIREMENTS = ['pychromecast==0.6.12']
|
||||
CONF_IGNORE_CEC = 'ignore_cec'
|
||||
CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png'
|
||||
SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||
SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \
|
||||
SUPPORT_NEXT_TRACK | SUPPORT_YOUTUBE
|
||||
KNOWN_HOSTS = []
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
cast = None
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the cast platform. """
|
||||
global cast
|
||||
import pychromecast
|
||||
cast = pychromecast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# import CEC IGNORE attributes
|
||||
ignore_cec = config.get(CONF_IGNORE_CEC, [])
|
||||
if isinstance(ignore_cec, list):
|
||||
cast.IGNORE_CEC += ignore_cec
|
||||
else:
|
||||
logger.error('Chromecast conig, %s must be a list.', CONF_IGNORE_CEC)
|
||||
|
||||
hosts = []
|
||||
|
||||
if discovery_info and discovery_info[0] not in KNOWN_HOSTS:
|
||||
hosts = [discovery_info[0]]
|
||||
|
||||
elif CONF_HOST in config:
|
||||
hosts = [config[CONF_HOST]]
|
||||
|
||||
else:
|
||||
hosts = (host_port[0] for host_port
|
||||
in cast.discover_chromecasts()
|
||||
if host_port[0] not in KNOWN_HOSTS)
|
||||
|
||||
casts = []
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
casts.append(CastDevice(host))
|
||||
except cast.ChromecastConnectionError:
|
||||
pass
|
||||
else:
|
||||
KNOWN_HOSTS.append(host)
|
||||
|
||||
add_devices(casts)
|
||||
|
||||
|
||||
class CastDevice(MediaPlayerDevice):
|
||||
""" Represents a Cast device on the network. """
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
def __init__(self, host):
|
||||
import pychromecast.controllers.youtube as youtube
|
||||
self.cast = cast.Chromecast(host)
|
||||
self.youtube = youtube.YouTubeController()
|
||||
self.cast.register_handler(self.youtube)
|
||||
|
||||
self.cast.socket_client.receiver_controller.register_status_listener(
|
||||
self)
|
||||
self.cast.socket_client.media_controller.register_status_listener(self)
|
||||
|
||||
self.cast_status = self.cast.status
|
||||
self.media_status = self.cast.media_controller.status
|
||||
|
||||
# Entity properties and methods
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device. """
|
||||
return self.cast.device.friendly_name
|
||||
|
||||
# MediaPlayerDevice properties and methods
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" State of the player. """
|
||||
if self.media_status is None:
|
||||
return STATE_UNKNOWN
|
||||
elif self.media_status.player_is_playing:
|
||||
return STATE_PLAYING
|
||||
elif self.media_status.player_is_paused:
|
||||
return STATE_PAUSED
|
||||
elif self.media_status.player_is_idle:
|
||||
return STATE_IDLE
|
||||
elif self.cast.is_idle:
|
||||
return STATE_OFF
|
||||
else:
|
||||
return STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
""" Volume level of the media player (0..1). """
|
||||
return self.cast_status.volume_level if self.cast_status else None
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
""" Boolean if volume is currently muted. """
|
||||
return self.cast_status.volume_muted if self.cast_status else None
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
""" Content ID of current playing media. """
|
||||
return self.media_status.content_id if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
""" Content type of current playing media. """
|
||||
if self.media_status is None:
|
||||
return None
|
||||
elif self.media_status.media_is_tvshow:
|
||||
return MEDIA_TYPE_TVSHOW
|
||||
elif self.media_status.media_is_movie:
|
||||
return MEDIA_TYPE_VIDEO
|
||||
elif self.media_status.media_is_musictrack:
|
||||
return MEDIA_TYPE_MUSIC
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
""" Duration of current playing media in seconds. """
|
||||
return self.media_status.duration if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
""" Image url of current playing media. """
|
||||
if self.media_status is None:
|
||||
return None
|
||||
|
||||
images = self.media_status.images
|
||||
|
||||
return images[0].url if images else None
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Title of current playing media. """
|
||||
return self.media_status.title if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
""" Artist of current playing media. (Music track only) """
|
||||
return self.media_status.artist if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_album(self):
|
||||
""" Album of current playing media. (Music track only) """
|
||||
return self.media_status.album_name if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_album_artist(self):
|
||||
""" Album arist of current playing media. (Music track only) """
|
||||
return self.media_status.album_artist if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_track(self):
|
||||
""" Track number of current playing media. (Music track only) """
|
||||
return self.media_status.track if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_series_title(self):
|
||||
""" Series title of current playing media. (TV Show only)"""
|
||||
return self.media_status.series_title if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_season(self):
|
||||
""" Season of current playing media. (TV Show only) """
|
||||
return self.media_status.season if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_episode(self):
|
||||
""" Episode of current playing media. (TV Show only) """
|
||||
return self.media_status.episode if self.media_status else None
|
||||
|
||||
@property
|
||||
def app_id(self):
|
||||
""" ID of the current running app. """
|
||||
return self.cast.app_id
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
""" Name of the current running app. """
|
||||
return self.cast.app_display_name
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
return SUPPORT_CAST
|
||||
|
||||
def turn_on(self):
|
||||
""" Turns on the ChromeCast. """
|
||||
# The only way we can turn the Chromecast is on is by launching an app
|
||||
if not self.cast.status or not self.cast.status.is_active_input:
|
||||
if self.cast.app_id:
|
||||
self.cast.quit_app()
|
||||
|
||||
self.cast.play_media(
|
||||
CAST_SPLASH, cast.STREAM_TYPE_BUFFERED)
|
||||
|
||||
def turn_off(self):
|
||||
""" Turns Chromecast off. """
|
||||
self.cast.quit_app()
|
||||
|
||||
def mute_volume(self, mute):
|
||||
""" mute the volume. """
|
||||
self.cast.set_volume_muted(mute)
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
""" set volume level, range 0..1. """
|
||||
self.cast.set_volume(volume)
|
||||
|
||||
def media_play(self):
|
||||
""" Send play commmand. """
|
||||
self.cast.media_controller.play()
|
||||
|
||||
def media_pause(self):
|
||||
""" Send pause command. """
|
||||
self.cast.media_controller.pause()
|
||||
|
||||
def media_previous_track(self):
|
||||
""" Send previous track command. """
|
||||
self.cast.media_controller.rewind()
|
||||
|
||||
def media_next_track(self):
|
||||
""" Send next track command. """
|
||||
self.cast.media_controller.skip()
|
||||
|
||||
def media_seek(self, position):
|
||||
""" Seek the media to a specific location. """
|
||||
self.cast.media_controller.seek(position)
|
||||
|
||||
def play_youtube(self, media_id):
|
||||
""" Plays a YouTube media. """
|
||||
self.youtube.play_video(media_id)
|
||||
|
||||
# implementation of chromecast status_listener methods
|
||||
|
||||
def new_cast_status(self, status):
|
||||
""" Called when a new cast status is received. """
|
||||
self.cast_status = status
|
||||
self.update_ha_state()
|
||||
|
||||
def new_media_status(self, status):
|
||||
""" Called when a new media status is received. """
|
||||
self.media_status = status
|
||||
self.update_ha_state()
|
||||
336
homeassistant/components/media_player/demo.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
homeassistant.components.media_player.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Demo implementation of the media player.
|
||||
"""
|
||||
from homeassistant.const import (
|
||||
STATE_PLAYING, STATE_PAUSED, STATE_OFF)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDevice, YOUTUBE_COVER_URL_FORMAT,
|
||||
MEDIA_TYPE_VIDEO, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW,
|
||||
SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, SUPPORT_YOUTUBE,
|
||||
SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_NEXT_TRACK)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the cast platform. """
|
||||
add_devices([
|
||||
DemoYoutubePlayer(
|
||||
'Living Room', 'eyU3bRy2x44',
|
||||
'♥♥ The Best Fireplace Video (3 hours)'),
|
||||
DemoYoutubePlayer('Bedroom', 'kxopViU98Xo', 'Epic sax guy 10 hours'),
|
||||
DemoMusicPlayer(), DemoTVShowPlayer(),
|
||||
])
|
||||
|
||||
|
||||
YOUTUBE_PLAYER_SUPPORT = \
|
||||
SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||
SUPPORT_YOUTUBE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF
|
||||
|
||||
MUSIC_PLAYER_SUPPORT = \
|
||||
SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||
SUPPORT_TURN_ON | SUPPORT_TURN_OFF
|
||||
|
||||
NETFLIX_PLAYER_SUPPORT = \
|
||||
SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF
|
||||
|
||||
|
||||
class AbstractDemoPlayer(MediaPlayerDevice):
|
||||
""" Base class for demo media players. """
|
||||
# We only implement the methods that we support
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
def __init__(self, name):
|
||||
self._name = name
|
||||
self._player_state = STATE_PLAYING
|
||||
self._volume_level = 1.0
|
||||
self._volume_muted = False
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
""" We will push an update after each command. """
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Name of the media player. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" State of the player. """
|
||||
return self._player_state
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
""" Volume level of the media player (0..1). """
|
||||
return self._volume_level
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
""" Boolean if volume is currently muted. """
|
||||
return self._volume_muted
|
||||
|
||||
def turn_on(self):
|
||||
""" turn the media player on. """
|
||||
self._player_state = STATE_PLAYING
|
||||
self.update_ha_state()
|
||||
|
||||
def turn_off(self):
|
||||
""" turn the media player off. """
|
||||
self._player_state = STATE_OFF
|
||||
self.update_ha_state()
|
||||
|
||||
def mute_volume(self, mute):
|
||||
""" mute the volume. """
|
||||
self._volume_muted = mute
|
||||
self.update_ha_state()
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
""" set volume level, range 0..1. """
|
||||
self._volume_level = volume
|
||||
self.update_ha_state()
|
||||
|
||||
def media_play(self):
|
||||
""" Send play commmand. """
|
||||
self._player_state = STATE_PLAYING
|
||||
self.update_ha_state()
|
||||
|
||||
def media_pause(self):
|
||||
""" Send pause command. """
|
||||
self._player_state = STATE_PAUSED
|
||||
self.update_ha_state()
|
||||
|
||||
|
||||
class DemoYoutubePlayer(AbstractDemoPlayer):
|
||||
""" A Demo media player that only supports YouTube. """
|
||||
# We only implement the methods that we support
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
def __init__(self, name, youtube_id=None, media_title=None):
|
||||
super().__init__(name)
|
||||
self.youtube_id = youtube_id
|
||||
self._media_title = media_title
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
""" Content ID of current playing media. """
|
||||
return self.youtube_id
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
""" Content type of current playing media. """
|
||||
return MEDIA_TYPE_VIDEO
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
""" Duration of current playing media in seconds. """
|
||||
return 360
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
""" Image url of current playing media. """
|
||||
return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id)
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Title of current playing media. """
|
||||
return self._media_title
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
""" Current running app. """
|
||||
return "YouTube"
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
return YOUTUBE_PLAYER_SUPPORT
|
||||
|
||||
def play_youtube(self, media_id):
|
||||
""" Plays a YouTube media. """
|
||||
self.youtube_id = media_id
|
||||
self._media_title = 'some YouTube video'
|
||||
self.update_ha_state()
|
||||
|
||||
|
||||
class DemoMusicPlayer(AbstractDemoPlayer):
|
||||
""" A Demo media player that only supports YouTube. """
|
||||
# We only implement the methods that we support
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
tracks = [
|
||||
('Technohead', 'I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)'),
|
||||
('Paul Elstak', 'Luv U More'),
|
||||
('Dune', 'Hardcore Vibes'),
|
||||
('Nakatomi', 'Children Of The Night'),
|
||||
('Party Animals',
|
||||
'Have You Ever Been Mellow? (Flamman & Abraxas Radio Mix)'),
|
||||
('Rob G.*', 'Ecstasy, You Got What I Need'),
|
||||
('Lipstick', "I'm A Raver"),
|
||||
('4 Tune Fairytales', 'My Little Fantasy (Radio Edit)'),
|
||||
('Prophet', "The Big Boys Don't Cry"),
|
||||
('Lovechild', 'All Out Of Love (DJ Weirdo & Sim Remix)'),
|
||||
('Stingray & Sonic Driver', 'Cold As Ice (El Bruto Remix)'),
|
||||
('Highlander', 'Hold Me Now (Bass-D & King Matthew Remix)'),
|
||||
('Juggernaut', 'Ruffneck Rules Da Artcore Scene (12" Edit)'),
|
||||
('Diss Reaction', 'Jiiieehaaaa '),
|
||||
('Flamman And Abraxas', 'Good To Go (Radio Mix)'),
|
||||
('Critical Mass', 'Dancing Together'),
|
||||
('Charly Lownoise & Mental Theo',
|
||||
'Ultimate Sex Track (Bass-D & King Matthew Remix)'),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('Walkman')
|
||||
self._cur_track = 0
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
""" Content ID of current playing media. """
|
||||
return 'bounzz-1'
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
""" Content type of current playing media. """
|
||||
return MEDIA_TYPE_MUSIC
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
""" Duration of current playing media in seconds. """
|
||||
return 213
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
""" Image url of current playing media. """
|
||||
return 'https://graph.facebook.com/107771475912710/picture'
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Title of current playing media. """
|
||||
return self.tracks[self._cur_track][1]
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
""" Artist of current playing media. (Music track only) """
|
||||
return self.tracks[self._cur_track][0]
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
""" Album of current playing media. (Music track only) """
|
||||
# pylint: disable=no-self-use
|
||||
return "Bounzz"
|
||||
|
||||
@property
|
||||
def media_track(self):
|
||||
""" Track number of current playing media. (Music track only) """
|
||||
return self._cur_track + 1
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
support = MUSIC_PLAYER_SUPPORT
|
||||
|
||||
if self._cur_track > 1:
|
||||
support |= SUPPORT_PREVIOUS_TRACK
|
||||
|
||||
if self._cur_track < len(self.tracks)-1:
|
||||
support |= SUPPORT_NEXT_TRACK
|
||||
|
||||
return support
|
||||
|
||||
def media_previous_track(self):
|
||||
""" Send previous track command. """
|
||||
if self._cur_track > 0:
|
||||
self._cur_track -= 1
|
||||
self.update_ha_state()
|
||||
|
||||
def media_next_track(self):
|
||||
""" Send next track command. """
|
||||
if self._cur_track < len(self.tracks)-1:
|
||||
self._cur_track += 1
|
||||
self.update_ha_state()
|
||||
|
||||
|
||||
class DemoTVShowPlayer(AbstractDemoPlayer):
|
||||
""" A Demo media player that only supports YouTube. """
|
||||
# We only implement the methods that we support
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('Lounge room')
|
||||
self._cur_episode = 1
|
||||
self._episode_count = 13
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
""" Content ID of current playing media. """
|
||||
return 'house-of-cards-1'
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
""" Content type of current playing media. """
|
||||
return MEDIA_TYPE_TVSHOW
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
""" Duration of current playing media in seconds. """
|
||||
return 3600
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
""" Image url of current playing media. """
|
||||
return 'https://graph.facebook.com/HouseofCards/picture'
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Title of current playing media. """
|
||||
return 'Chapter {}'.format(self._cur_episode)
|
||||
|
||||
@property
|
||||
def media_series_title(self):
|
||||
""" Series title of current playing media. (TV Show only)"""
|
||||
return 'House of Cards'
|
||||
|
||||
@property
|
||||
def media_season(self):
|
||||
""" Season of current playing media. (TV Show only) """
|
||||
return 1
|
||||
|
||||
@property
|
||||
def media_episode(self):
|
||||
""" Episode of current playing media. (TV Show only) """
|
||||
return self._cur_episode
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
""" Current running app. """
|
||||
return "Netflix"
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
support = NETFLIX_PLAYER_SUPPORT
|
||||
|
||||
if self._cur_episode > 1:
|
||||
support |= SUPPORT_PREVIOUS_TRACK
|
||||
|
||||
if self._cur_episode < self._episode_count:
|
||||
support |= SUPPORT_NEXT_TRACK
|
||||
|
||||
return support
|
||||
|
||||
def media_previous_track(self):
|
||||
""" Send previous track command. """
|
||||
if self._cur_episode > 1:
|
||||
self._cur_episode -= 1
|
||||
self.update_ha_state()
|
||||
|
||||
def media_next_track(self):
|
||||
""" Send next track command. """
|
||||
if self._cur_episode < self._episode_count:
|
||||
self._cur_episode += 1
|
||||
self.update_ha_state()
|
||||
191
homeassistant/components/media_player/denon.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
homeassistant.components.media_player.denon
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides an interface to Denon Network Receivers.
|
||||
Developed for a Denon DRA-N5, see
|
||||
http://www.denon.co.uk/chg/product/compactsystems/networkmusicsystems/ceolpiccolo
|
||||
|
||||
A few notes:
|
||||
- As long as this module is active and connected, the receiver does
|
||||
not seem to accept additional telnet connections.
|
||||
|
||||
- Be careful with the volume. 50% or even 100% are very loud.
|
||||
|
||||
- To be able to wake up the receiver, activate the "remote" setting
|
||||
in the receiver's settings.
|
||||
|
||||
- Play and pause are supported, toggling is not possible.
|
||||
|
||||
- Seeking cannot be implemented as the UI sends absolute positions.
|
||||
Only seeking via simulated button presses is possible.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use your Denon you will need to add something like the following to
|
||||
your config/configuration.yaml:
|
||||
|
||||
media_player:
|
||||
platform: denon
|
||||
name: Music station
|
||||
host: 192.168.0.123
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The ip of the player. Example: 192.168.0.123
|
||||
|
||||
name
|
||||
*Optional
|
||||
The name of the device.
|
||||
"""
|
||||
import telnetlib
|
||||
import logging
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_VOLUME_SET,
|
||||
SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_TURN_ON, SUPPORT_TURN_OFF,
|
||||
DOMAIN)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_DENON = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
|
||||
SUPPORT_TURN_ON | SUPPORT_TURN_OFF
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the Denon platform. """
|
||||
if not config.get(CONF_HOST):
|
||||
_LOGGER.error(
|
||||
"Missing required configuration items in %s: %s",
|
||||
DOMAIN,
|
||||
CONF_HOST)
|
||||
return False
|
||||
|
||||
add_devices([
|
||||
DenonDevice(
|
||||
config.get('name', 'Music station'),
|
||||
config.get('host'))
|
||||
])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class DenonDevice(MediaPlayerDevice):
|
||||
""" Represents a Denon device. """
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
def __init__(self, name, host):
|
||||
self._name = name
|
||||
self._host = host
|
||||
self._telnet = telnetlib.Telnet(self._host)
|
||||
|
||||
def query(self, message):
|
||||
""" Send request and await response from server """
|
||||
try:
|
||||
# unspecified command, should be ignored
|
||||
self._telnet.write("?".encode('UTF-8') + b'\r')
|
||||
except (EOFError, BrokenPipeError, ConnectionResetError):
|
||||
self._telnet.open(self._host)
|
||||
|
||||
self._telnet.read_very_eager() # skip what is not requested
|
||||
|
||||
self._telnet.write(message.encode('ASCII') + b'\r')
|
||||
# timeout 200ms, defined by protocol
|
||||
resp = self._telnet.read_until(b'\r', timeout=0.2)\
|
||||
.decode('UTF-8').strip()
|
||||
|
||||
if message == "PW?":
|
||||
# workaround; PW? sends also SISTATUS
|
||||
self._telnet.read_until(b'\r', timeout=0.2)
|
||||
|
||||
return resp
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the device. """
|
||||
pwstate = self.query('PW?')
|
||||
if pwstate == "PWSTANDBY":
|
||||
return STATE_OFF
|
||||
if pwstate == "PWON":
|
||||
return STATE_ON
|
||||
|
||||
return STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
""" Volume level of the media player (0..1). """
|
||||
return int(self.query('MV?')[len('MV'):]) / 60
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
""" Boolean if volume is currently muted. """
|
||||
return self.query('MU?') == "MUON"
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Current media source. """
|
||||
return self.query('SI?')[len('SI'):]
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
return SUPPORT_DENON
|
||||
|
||||
def turn_off(self):
|
||||
""" turn_off media player. """
|
||||
self.query('PWSTANDBY')
|
||||
|
||||
def volume_up(self):
|
||||
""" volume_up media player. """
|
||||
self.query('MVUP')
|
||||
|
||||
def volume_down(self):
|
||||
""" volume_down media player. """
|
||||
self.query('MVDOWN')
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
""" set volume level, range 0..1. """
|
||||
# 60dB max
|
||||
self.query('MV' + str(round(volume * 60)).zfill(2))
|
||||
|
||||
def mute_volume(self, mute):
|
||||
""" mute (true) or unmute (false) media player. """
|
||||
self.query('MU' + ('ON' if mute else 'OFF'))
|
||||
|
||||
def media_play_pause(self):
|
||||
""" media_play_pause media player. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def media_play(self):
|
||||
""" media_play media player. """
|
||||
self.query('NS9A')
|
||||
|
||||
def media_pause(self):
|
||||
""" media_pause media player. """
|
||||
self.query('NS9B')
|
||||
|
||||
def media_next_track(self):
|
||||
""" Send next track command. """
|
||||
self.query('NS9D')
|
||||
|
||||
def media_previous_track(self):
|
||||
self.query('NS9E')
|
||||
|
||||
def media_seek(self, position):
|
||||
raise NotImplementedError()
|
||||
|
||||
def turn_on(self):
|
||||
""" turn the media player on. """
|
||||
self.query('PWON')
|
||||
306
homeassistant/components/media_player/kodi.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
homeassistant.components.media_player.kodi
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides an interface to the XBMC/Kodi JSON-RPC API
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Kodi you will need to add something like the following to
|
||||
your configuration.yaml file.
|
||||
|
||||
media_player:
|
||||
platform: kodi
|
||||
name: Kodi
|
||||
url: http://192.168.0.123/jsonrpc
|
||||
user: kodi
|
||||
password: my_secure_password
|
||||
|
||||
Variables:
|
||||
|
||||
name
|
||||
*Optional
|
||||
The name of the device.
|
||||
|
||||
url
|
||||
*Required
|
||||
The URL of the XBMC/Kodi JSON-RPC API. Example: http://192.168.0.123/jsonrpc
|
||||
|
||||
user
|
||||
*Optional
|
||||
The XBMC/Kodi HTTP username.
|
||||
|
||||
password
|
||||
*Optional
|
||||
The XBMC/Kodi HTTP password.
|
||||
"""
|
||||
import urllib
|
||||
import logging
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_SEEK, SUPPORT_VOLUME_SET,
|
||||
SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK)
|
||||
from homeassistant.const import (
|
||||
STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF)
|
||||
|
||||
try:
|
||||
import jsonrpc_requests
|
||||
except ImportError:
|
||||
jsonrpc_requests = None
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['jsonrpc-requests==0.1']
|
||||
|
||||
SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the kodi platform. """
|
||||
|
||||
global jsonrpc_requests # pylint: disable=invalid-name
|
||||
if jsonrpc_requests is None:
|
||||
import jsonrpc_requests as jsonrpc_requests_
|
||||
jsonrpc_requests = jsonrpc_requests_
|
||||
|
||||
add_devices([
|
||||
KodiDevice(
|
||||
config.get('name', 'Kodi'),
|
||||
config.get('url'),
|
||||
auth=(
|
||||
config.get('user', ''),
|
||||
config.get('password', ''))),
|
||||
])
|
||||
|
||||
|
||||
def _get_image_url(kodi_url):
|
||||
""" Helper function that parses the thumbnail URLs used by Kodi. """
|
||||
url_components = urllib.parse.urlparse(kodi_url)
|
||||
|
||||
if url_components.scheme == 'image':
|
||||
return urllib.parse.unquote(url_components.netloc)
|
||||
|
||||
|
||||
class KodiDevice(MediaPlayerDevice):
|
||||
""" Represents a XBMC/Kodi device. """
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
def __init__(self, name, url, auth=None):
|
||||
self._name = name
|
||||
self._url = url
|
||||
self._server = jsonrpc_requests.Server(url, auth=auth)
|
||||
self._players = None
|
||||
self._properties = None
|
||||
self._item = None
|
||||
self._app_properties = None
|
||||
|
||||
self.update()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device. """
|
||||
return self._name
|
||||
|
||||
def _get_players(self):
|
||||
""" Returns the active player objects or None """
|
||||
try:
|
||||
return self._server.Player.GetActivePlayers()
|
||||
except jsonrpc_requests.jsonrpc.TransportError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the device. """
|
||||
if self._players is None:
|
||||
return STATE_OFF
|
||||
|
||||
if len(self._players) == 0:
|
||||
return STATE_IDLE
|
||||
|
||||
if self._properties['speed'] == 0:
|
||||
return STATE_PAUSED
|
||||
else:
|
||||
return STATE_PLAYING
|
||||
|
||||
def update(self):
|
||||
""" Retrieve latest state. """
|
||||
self._players = self._get_players()
|
||||
|
||||
if self._players is not None and len(self._players) > 0:
|
||||
player_id = self._players[0]['playerid']
|
||||
|
||||
assert isinstance(player_id, int)
|
||||
|
||||
self._properties = self._server.Player.GetProperties(
|
||||
player_id,
|
||||
['time', 'totaltime', 'speed']
|
||||
)
|
||||
|
||||
self._item = self._server.Player.GetItem(
|
||||
player_id,
|
||||
['title', 'file', 'uniqueid', 'thumbnail', 'artist']
|
||||
)['item']
|
||||
|
||||
self._app_properties = self._server.Application.GetProperties(
|
||||
['volume', 'muted']
|
||||
)
|
||||
else:
|
||||
self._properties = None
|
||||
self._item = None
|
||||
self._app_properties = None
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
""" Volume level of the media player (0..1). """
|
||||
if self._app_properties is not None:
|
||||
return self._app_properties['volume'] / 100.0
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
""" Boolean if volume is currently muted. """
|
||||
if self._app_properties is not None:
|
||||
return self._app_properties['muted']
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
""" Content ID of current playing media. """
|
||||
if self._item is not None:
|
||||
return self._item['uniqueid']
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
""" Content type of current playing media. """
|
||||
if self._players is not None and len(self._players) > 0:
|
||||
return self._players[0]['type']
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
""" Duration of current playing media in seconds. """
|
||||
if self._properties is not None:
|
||||
total_time = self._properties['totaltime']
|
||||
|
||||
return (
|
||||
total_time['hours'] * 3600 +
|
||||
total_time['minutes'] * 60 +
|
||||
total_time['seconds'])
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
""" Image url of current playing media. """
|
||||
if self._item is not None:
|
||||
return _get_image_url(self._item['thumbnail'])
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Title of current playing media. """
|
||||
# find a string we can use as a title
|
||||
if self._item is not None:
|
||||
return self._item.get(
|
||||
'title',
|
||||
self._item.get(
|
||||
'label',
|
||||
self._item.get(
|
||||
'file',
|
||||
'unknown')))
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
return SUPPORT_KODI
|
||||
|
||||
def turn_off(self):
|
||||
""" turn_off media player. """
|
||||
self._server.System.Shutdown()
|
||||
self.update_ha_state()
|
||||
|
||||
def volume_up(self):
|
||||
""" volume_up media player. """
|
||||
assert self._server.Input.ExecuteAction('volumeup') == 'OK'
|
||||
self.update_ha_state()
|
||||
|
||||
def volume_down(self):
|
||||
""" volume_down media player. """
|
||||
assert self._server.Input.ExecuteAction('volumedown') == 'OK'
|
||||
self.update_ha_state()
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
""" set volume level, range 0..1. """
|
||||
self._server.Application.SetVolume(int(volume * 100))
|
||||
self.update_ha_state()
|
||||
|
||||
def mute_volume(self, mute):
|
||||
""" mute (true) or unmute (false) media player. """
|
||||
self._server.Application.SetMute(mute)
|
||||
self.update_ha_state()
|
||||
|
||||
def _set_play_state(self, state):
|
||||
""" Helper method for play/pause/toggle. """
|
||||
players = self._get_players()
|
||||
|
||||
if len(players) != 0:
|
||||
self._server.Player.PlayPause(players[0]['playerid'], state)
|
||||
|
||||
self.update_ha_state()
|
||||
|
||||
def media_play_pause(self):
|
||||
""" media_play_pause media player. """
|
||||
self._set_play_state('toggle')
|
||||
|
||||
def media_play(self):
|
||||
""" media_play media player. """
|
||||
self._set_play_state(True)
|
||||
|
||||
def media_pause(self):
|
||||
""" media_pause media player. """
|
||||
self._set_play_state(False)
|
||||
|
||||
def _goto(self, direction):
|
||||
""" Helper method used for previous/next track. """
|
||||
players = self._get_players()
|
||||
|
||||
if len(players) != 0:
|
||||
self._server.Player.GoTo(players[0]['playerid'], direction)
|
||||
|
||||
self.update_ha_state()
|
||||
|
||||
def media_next_track(self):
|
||||
""" Send next track command. """
|
||||
self._goto('next')
|
||||
|
||||
def media_previous_track(self):
|
||||
""" Send next track command. """
|
||||
# first seek to position 0, Kodi seems to go to the beginning
|
||||
# of the current track current track is not at the beginning
|
||||
self.media_seek(0)
|
||||
self._goto('previous')
|
||||
|
||||
def media_seek(self, position):
|
||||
""" Send seek command. """
|
||||
players = self._get_players()
|
||||
|
||||
time = {}
|
||||
|
||||
time['milliseconds'] = int((position % 1) * 1000)
|
||||
position = int(position)
|
||||
|
||||
time['seconds'] = int(position % 60)
|
||||
position /= 60
|
||||
|
||||
time['minutes'] = int(position % 60)
|
||||
position /= 60
|
||||
|
||||
time['hours'] = int(position)
|
||||
|
||||
if len(players) != 0:
|
||||
self._server.Player.Seek(players[0]['playerid'], time)
|
||||
|
||||
self.update_ha_state()
|
||||
|
||||
def turn_on(self):
|
||||
""" turn the media player on. """
|
||||
raise NotImplementedError()
|
||||
|
||||
def play_youtube(self, media_id):
|
||||
""" Plays a YouTube media. """
|
||||
raise NotImplementedError()
|
||||
228
homeassistant/components/media_player/mpd.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
homeassistant.components.media_player.mpd
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides functionality to interact with a Music Player Daemon.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use MPD you will need to add something like the following to your
|
||||
configuration.yaml file.
|
||||
|
||||
media_player:
|
||||
platform: mpd
|
||||
server: 127.0.0.1
|
||||
port: 6600
|
||||
location: bedroom
|
||||
password: superSecretPassword123
|
||||
|
||||
Variables:
|
||||
|
||||
server
|
||||
*Required
|
||||
IP address of the Music Player Daemon. Example: 192.168.1.32
|
||||
|
||||
port
|
||||
*Optional
|
||||
Port of the Music Player Daemon, defaults to 6600. Example: 6600
|
||||
|
||||
location
|
||||
*Optional
|
||||
Location of your Music Player Daemon.
|
||||
|
||||
password
|
||||
*Optional
|
||||
Password for your Music Player Daemon.
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
|
||||
try:
|
||||
import mpd
|
||||
except ImportError:
|
||||
mpd = None
|
||||
|
||||
|
||||
from homeassistant.const import (
|
||||
STATE_PLAYING, STATE_PAUSED, STATE_OFF)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDevice,
|
||||
SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_TURN_OFF,
|
||||
SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK,
|
||||
MEDIA_TYPE_MUSIC)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['python-mpd2==0.5.4']
|
||||
|
||||
SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \
|
||||
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the MPD platform. """
|
||||
|
||||
daemon = config.get('server', None)
|
||||
port = config.get('port', 6600)
|
||||
location = config.get('location', 'MPD')
|
||||
password = config.get('password', None)
|
||||
|
||||
global mpd # pylint: disable=invalid-name
|
||||
if mpd is None:
|
||||
import mpd as mpd_
|
||||
mpd = mpd_
|
||||
|
||||
# pylint: disable=no-member
|
||||
try:
|
||||
mpd_client = mpd.MPDClient()
|
||||
mpd_client.connect(daemon, port)
|
||||
|
||||
if password is not None:
|
||||
mpd_client.password(password)
|
||||
|
||||
mpd_client.close()
|
||||
mpd_client.disconnect()
|
||||
except socket.error:
|
||||
_LOGGER.error(
|
||||
"Unable to connect to MPD. "
|
||||
"Please check your settings")
|
||||
|
||||
return False
|
||||
except mpd.CommandError as error:
|
||||
|
||||
if "incorrect password" in str(error):
|
||||
_LOGGER.error(
|
||||
"MPD reported incorrect password. "
|
||||
"Please check your password.")
|
||||
|
||||
return False
|
||||
else:
|
||||
raise
|
||||
|
||||
add_devices([MpdDevice(daemon, port, location, password)])
|
||||
|
||||
|
||||
class MpdDevice(MediaPlayerDevice):
|
||||
""" Represents a MPD server. """
|
||||
|
||||
# MPD confuses pylint
|
||||
# pylint: disable=no-member, abstract-method
|
||||
|
||||
def __init__(self, server, port, location, password):
|
||||
self.server = server
|
||||
self.port = port
|
||||
self._name = location
|
||||
self.password = password
|
||||
self.status = None
|
||||
self.currentsong = None
|
||||
|
||||
self.client = mpd.MPDClient()
|
||||
self.client.timeout = 10
|
||||
self.client.idletimeout = None
|
||||
self.update()
|
||||
|
||||
def update(self):
|
||||
try:
|
||||
self.status = self.client.status()
|
||||
self.currentsong = self.client.currentsong()
|
||||
except mpd.ConnectionError:
|
||||
self.client.connect(self.server, self.port)
|
||||
|
||||
if self.password is not None:
|
||||
self.client.password(self.password)
|
||||
|
||||
self.status = self.client.status()
|
||||
self.currentsong = self.client.currentsong()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the media state. """
|
||||
if self.status['state'] == 'play':
|
||||
return STATE_PLAYING
|
||||
elif self.status['state'] == 'pause':
|
||||
return STATE_PAUSED
|
||||
else:
|
||||
return STATE_OFF
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
""" Content ID of current playing media. """
|
||||
return self.currentsong['id']
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
""" Content type of current playing media. """
|
||||
return MEDIA_TYPE_MUSIC
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
""" Duration of current playing media in seconds. """
|
||||
# Time does not exist for streams
|
||||
return self.currentsong.get('time')
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Title of current playing media. """
|
||||
return self.currentsong['title']
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
""" Artist of current playing media. (Music track only) """
|
||||
return self.currentsong.get('artist')
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
""" Album of current playing media. (Music track only) """
|
||||
return self.currentsong.get('album')
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
return int(self.status['volume'])/100
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
return SUPPORT_MPD
|
||||
|
||||
def turn_off(self):
|
||||
""" Service to exit the running MPD. """
|
||||
self.client.stop()
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
""" Sets volume """
|
||||
self.client.setvol(int(volume * 100))
|
||||
|
||||
def volume_up(self):
|
||||
""" Service to send the MPD the command for volume up. """
|
||||
current_volume = int(self.status['volume'])
|
||||
|
||||
if current_volume <= 100:
|
||||
self.client.setvol(current_volume + 5)
|
||||
|
||||
def volume_down(self):
|
||||
""" Service to send the MPD the command for volume down. """
|
||||
current_volume = int(self.status['volume'])
|
||||
|
||||
if current_volume >= 0:
|
||||
self.client.setvol(current_volume - 5)
|
||||
|
||||
def media_play(self):
|
||||
""" Service to send the MPD the command for play/pause. """
|
||||
self.client.pause(0)
|
||||
|
||||
def media_pause(self):
|
||||
""" Service to send the MPD the command for play/pause. """
|
||||
self.client.pause(1)
|
||||
|
||||
def media_next_track(self):
|
||||
""" Service to send the MPD the command for next track. """
|
||||
self.client.next()
|
||||
|
||||
def media_previous_track(self):
|
||||
""" Service to send the MPD the command for previous track. """
|
||||
self.client.previous()
|
||||
319
homeassistant/components/media_player/squeezebox.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
homeassistant.components.media_player.squeezebox
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Provides an interface to the Logitech SqueezeBox API
|
||||
|
||||
Configuration:
|
||||
|
||||
To use SqueezeBox add something something like the following to your
|
||||
configuration.yaml file.
|
||||
|
||||
media_player:
|
||||
platform: squeezebox
|
||||
host: 192.168.1.21
|
||||
port: 9090
|
||||
username: user
|
||||
password: password
|
||||
|
||||
Variables:
|
||||
|
||||
host
|
||||
*Required
|
||||
The host name or address of the Logitech Media Server.
|
||||
|
||||
port
|
||||
*Optional
|
||||
Telnet port to Logitech Media Server, default 9090.
|
||||
|
||||
usermame
|
||||
*Optional
|
||||
Username, if password protection is enabled.
|
||||
|
||||
password
|
||||
*Optional
|
||||
Password, if password protection is enabled.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import telnetlib
|
||||
import urllib.parse
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_SEEK, SUPPORT_VOLUME_SET,
|
||||
SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_TURN_ON, SUPPORT_TURN_OFF,
|
||||
MEDIA_TYPE_MUSIC, DOMAIN)
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_USERNAME, CONF_PASSWORD,
|
||||
STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_UNKNOWN)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_SQUEEZEBOX = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\
|
||||
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK |\
|
||||
SUPPORT_TURN_ON | SUPPORT_TURN_OFF
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
""" Sets up the squeezebox platform. """
|
||||
if not config.get(CONF_HOST):
|
||||
_LOGGER.error(
|
||||
"Missing required configuration items in %s: %s",
|
||||
DOMAIN,
|
||||
CONF_HOST)
|
||||
return False
|
||||
|
||||
lms = LogitechMediaServer(
|
||||
config.get(CONF_HOST),
|
||||
config.get('port', '9090'),
|
||||
config.get(CONF_USERNAME),
|
||||
config.get(CONF_PASSWORD))
|
||||
|
||||
if not lms.init_success:
|
||||
return False
|
||||
|
||||
add_devices(lms.create_players())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class LogitechMediaServer(object):
|
||||
""" Represents a Logitech media server. """
|
||||
|
||||
def __init__(self, host, port, username, password):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._username = username
|
||||
self._password = password
|
||||
self.http_port = self._get_http_port()
|
||||
self.init_success = True if self.http_port else False
|
||||
|
||||
def _get_http_port(self):
|
||||
""" Get http port from media server, it is used to get cover art. """
|
||||
http_port = None
|
||||
try:
|
||||
http_port = self.query('pref', 'httpport', '?')
|
||||
if not http_port:
|
||||
_LOGGER.error(
|
||||
"Unable to read data from server %s:%s",
|
||||
self.host,
|
||||
self.port)
|
||||
return
|
||||
return http_port
|
||||
except ConnectionError as ex:
|
||||
_LOGGER.error(
|
||||
"Failed to connect to server %s:%s - %s",
|
||||
self.host,
|
||||
self.port,
|
||||
ex)
|
||||
return
|
||||
|
||||
def create_players(self):
|
||||
""" Create a list of SqueezeBoxDevices connected to the LMS. """
|
||||
players = []
|
||||
count = self.query('player', 'count', '?')
|
||||
for index in range(0, int(count)):
|
||||
player_id = self.query('player', 'id', str(index), '?')
|
||||
player = SqueezeBoxDevice(self, player_id)
|
||||
players.append(player)
|
||||
return players
|
||||
|
||||
def query(self, *parameters):
|
||||
""" Send request and await response from server. """
|
||||
telnet = telnetlib.Telnet(self.host, self.port)
|
||||
if self._username and self._password:
|
||||
telnet.write('login {username} {password}\n'.format(
|
||||
username=self._username,
|
||||
password=self._password).encode('UTF-8'))
|
||||
telnet.read_until(b'\n', timeout=3)
|
||||
message = '{}\n'.format(' '.join(parameters))
|
||||
telnet.write(message.encode('UTF-8'))
|
||||
response = telnet.read_until(b'\n', timeout=3)\
|
||||
.decode('UTF-8')\
|
||||
.split(' ')[-1]\
|
||||
.strip()
|
||||
telnet.write(b'exit\n')
|
||||
return urllib.parse.unquote(response)
|
||||
|
||||
def get_player_status(self, player):
|
||||
""" Get ithe status of a player. """
|
||||
# (title) : Song title
|
||||
# Requested Information
|
||||
# a (artist): Artist name 'artist'
|
||||
# d (duration): Song duration in seconds 'duration'
|
||||
# K (artwork_url): URL to remote artwork
|
||||
tags = 'adK'
|
||||
new_status = {}
|
||||
telnet = telnetlib.Telnet(self.host, self.port)
|
||||
telnet.write('{player} status - 1 tags:{tags}\n'.format(
|
||||
player=player,
|
||||
tags=tags
|
||||
).encode('UTF-8'))
|
||||
response = telnet.read_until(b'\n', timeout=3)\
|
||||
.decode('UTF-8')\
|
||||
.split(' ')
|
||||
telnet.write(b'exit\n')
|
||||
for item in response:
|
||||
parts = urllib.parse.unquote(item).partition(':')
|
||||
new_status[parts[0]] = parts[2]
|
||||
return new_status
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
# pylint: disable=too-many-public-methods
|
||||
class SqueezeBoxDevice(MediaPlayerDevice):
|
||||
""" Represents a SqueezeBox device. """
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, lms, player_id):
|
||||
super(SqueezeBoxDevice, self).__init__()
|
||||
self._lms = lms
|
||||
self._id = player_id
|
||||
self._name = self._lms.query(self._id, 'name', '?')
|
||||
self._status = self._lms.get_player_status(self._id)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Returns the name of the device. """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Returns the state of the device. """
|
||||
if 'power' in self._status and self._status['power'] == '0':
|
||||
return STATE_OFF
|
||||
if 'mode' in self._status:
|
||||
if self._status['mode'] == 'pause':
|
||||
return STATE_PAUSED
|
||||
if self._status['mode'] == 'play':
|
||||
return STATE_PLAYING
|
||||
if self._status['mode'] == 'stop':
|
||||
return STATE_IDLE
|
||||
return STATE_UNKNOWN
|
||||
|
||||
def update(self):
|
||||
""" Retrieve latest state. """
|
||||
self._status = self._lms.get_player_status(self._id)
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
""" Volume level of the media player (0..1). """
|
||||
if 'mixer volume' in self._status:
|
||||
return int(self._status['mixer volume']) / 100.0
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
if 'mixer volume' in self._status:
|
||||
return self._status['mixer volume'].startswith('-')
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
""" Content ID of current playing media. """
|
||||
if 'current_title' in self._status:
|
||||
return self._status['current_title']
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
""" Content type of current playing media. """
|
||||
return MEDIA_TYPE_MUSIC
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
""" Duration of current playing media in seconds. """
|
||||
if 'duration' in self._status:
|
||||
return int(float(self._status['duration']))
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
""" Image url of current playing media. """
|
||||
if 'artwork_url' in self._status:
|
||||
return self._status['artwork_url']
|
||||
return 'http://{server}:{port}/music/current/cover.jpg?player={player}'\
|
||||
.format(
|
||||
server=self._lms.host,
|
||||
port=self._lms.http_port,
|
||||
player=self._id)
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Title of current playing media. """
|
||||
if 'artist' in self._status and 'title' in self._status:
|
||||
return '{artist} - {title}'.format(
|
||||
artist=self._status['artist'],
|
||||
title=self._status['title']
|
||||
)
|
||||
if 'current_title' in self._status:
|
||||
return self._status['current_title']
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
""" Flags of media commands that are supported. """
|
||||
return SUPPORT_SQUEEZEBOX
|
||||
|
||||
def turn_off(self):
|
||||
""" turn_off media player. """
|
||||
self._lms.query(self._id, 'power', '0')
|
||||
self.update_ha_state()
|
||||
|
||||
def volume_up(self):
|
||||
""" volume_up media player. """
|
||||
self._lms.query(self._id, 'mixer', 'volume', '+5')
|
||||
self.update_ha_state()
|
||||
|
||||
def volume_down(self):
|
||||
""" volume_down media player. """
|
||||
self._lms.query(self._id, 'mixer', 'volume', '-5')
|
||||
self.update_ha_state()
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
""" set volume level, range 0..1. """
|
||||
volume_percent = str(int(volume*100))
|
||||
self._lms.query(self._id, 'mixer', 'volume', volume_percent)
|
||||
self.update_ha_state()
|
||||
|
||||
def mute_volume(self, mute):
|
||||
""" mute (true) or unmute (false) media player. """
|
||||
mute_numeric = '1' if mute else '0'
|
||||
self._lms.query(self._id, 'mixer', 'muting', mute_numeric)
|
||||
self.update_ha_state()
|
||||
|
||||
def media_play_pause(self):
|
||||
""" media_play_pause media player. """
|
||||
self._lms.query(self._id, 'pause')
|
||||
self.update_ha_state()
|
||||
|
||||
def media_play(self):
|
||||
""" media_play media player. """
|
||||
self._lms.query(self._id, 'play')
|
||||
self.update_ha_state()
|
||||
|
||||
def media_pause(self):
|
||||
""" media_pause media player. """
|
||||
self._lms.query(self._id, 'pause', '0')
|
||||
self.update_ha_state()
|
||||
|
||||
def media_next_track(self):
|
||||
""" Send next track command. """
|
||||
self._lms.query(self._id, 'playlist', 'index', '+1')
|
||||
self.update_ha_state()
|
||||
|
||||
def media_previous_track(self):
|
||||
""" Send next track command. """
|
||||
self._lms.query(self._id, 'playlist', 'index', '-1')
|
||||
self.update_ha_state()
|
||||
|
||||
def media_seek(self, position):
|
||||
""" Send seek command. """
|
||||
self._lms.query(self._id, 'time', position)
|
||||
self.update_ha_state()
|
||||
|
||||
def turn_on(self):
|
||||
""" turn the media player on. """
|
||||
self._lms.query(self._id, 'power', '1')
|
||||
self.update_ha_state()
|
||||
|
||||
def play_youtube(self, media_id):
|
||||
""" Plays a YouTube media. """
|
||||
raise NotImplementedError()
|
||||
103
homeassistant/components/modbus.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
homeassistant.components.modbus
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Modbus component, using pymodbus (python3 branch).
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Modbus component you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
#Modbus TCP
|
||||
modbus:
|
||||
type: tcp
|
||||
host: 127.0.0.1
|
||||
port: 2020
|
||||
|
||||
#Modbus RTU
|
||||
modbus:
|
||||
type: serial
|
||||
method: rtu
|
||||
port: /dev/ttyUSB0
|
||||
baudrate: 9600
|
||||
stopbits: 1
|
||||
bytesize: 8
|
||||
parity: N
|
||||
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import (EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
|
||||
DOMAIN = "modbus"
|
||||
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['https://github.com/bashwork/pymodbus/archive/'
|
||||
'd7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0']
|
||||
|
||||
# Type of network
|
||||
MEDIUM = "type"
|
||||
|
||||
# if MEDIUM == "serial"
|
||||
METHOD = "method"
|
||||
SERIAL_PORT = "port"
|
||||
BAUDRATE = "baudrate"
|
||||
STOPBITS = "stopbits"
|
||||
BYTESIZE = "bytesize"
|
||||
PARITY = "parity"
|
||||
|
||||
# if MEDIUM == "tcp" or "udp"
|
||||
HOST = "host"
|
||||
IP_PORT = "port"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
NETWORK = None
|
||||
TYPE = None
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Setup Modbus component. """
|
||||
|
||||
# Modbus connection type
|
||||
# pylint: disable=global-statement, import-error
|
||||
global TYPE
|
||||
TYPE = config[DOMAIN][MEDIUM]
|
||||
|
||||
# Connect to Modbus network
|
||||
# pylint: disable=global-statement, import-error
|
||||
global NETWORK
|
||||
|
||||
if TYPE == "serial":
|
||||
from pymodbus.client.sync import ModbusSerialClient as ModbusClient
|
||||
NETWORK = ModbusClient(method=config[DOMAIN][METHOD],
|
||||
port=config[DOMAIN][SERIAL_PORT],
|
||||
baudrate=config[DOMAIN][BAUDRATE],
|
||||
stopbits=config[DOMAIN][STOPBITS],
|
||||
bytesize=config[DOMAIN][BYTESIZE],
|
||||
parity=config[DOMAIN][PARITY])
|
||||
elif TYPE == "tcp":
|
||||
from pymodbus.client.sync import ModbusTcpClient as ModbusClient
|
||||
NETWORK = ModbusClient(host=config[DOMAIN][HOST],
|
||||
port=config[DOMAIN][IP_PORT])
|
||||
elif TYPE == "udp":
|
||||
from pymodbus.client.sync import ModbusUdpClient as ModbusClient
|
||||
NETWORK = ModbusClient(host=config[DOMAIN][HOST],
|
||||
port=config[DOMAIN][IP_PORT])
|
||||
else:
|
||||
return False
|
||||
|
||||
def stop_modbus(event):
|
||||
""" Stop Modbus service. """
|
||||
NETWORK.close()
|
||||
|
||||
def start_modbus(event):
|
||||
""" Start Modbus service. """
|
||||
NETWORK.connect()
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus)
|
||||
|
||||
# Tells the bootstrapper that the component was successfully initialized
|
||||
return True
|
||||
257
homeassistant/components/mqtt.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
homeassistant.components.mqtt
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
MQTT component, using paho-mqtt. This component needs a MQTT broker like
|
||||
Mosquitto or Mosca. The Eclipse Foundation is running a public MQTT server
|
||||
at iot.eclipse.org. If you prefer to use that one, keep in mind to adjust
|
||||
the topic/client ID and that your messages are public.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use MQTT you will need to add something like the following to your
|
||||
config/configuration.yaml.
|
||||
|
||||
mqtt:
|
||||
broker: 127.0.0.1
|
||||
|
||||
Or, if you want more options:
|
||||
|
||||
mqtt:
|
||||
broker: 127.0.0.1
|
||||
port: 1883
|
||||
client_id: home-assistant-1
|
||||
keepalive: 60
|
||||
username: your_username
|
||||
password: your_secret_password
|
||||
|
||||
Variables:
|
||||
|
||||
broker
|
||||
*Required
|
||||
This is the IP address of your MQTT broker, e.g. 192.168.1.32.
|
||||
|
||||
port
|
||||
*Optional
|
||||
The network port to connect to. Default is 1883.
|
||||
|
||||
client_id
|
||||
*Optional
|
||||
Client ID that Home Assistant will use. Has to be unique on the server.
|
||||
Default is a random generated one.
|
||||
|
||||
keepalive
|
||||
*Optional
|
||||
The keep alive in seconds for this client. Default is 60.
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.util as util
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "mqtt"
|
||||
|
||||
MQTT_CLIENT = None
|
||||
|
||||
DEFAULT_PORT = 1883
|
||||
DEFAULT_KEEPALIVE = 60
|
||||
|
||||
SERVICE_PUBLISH = 'publish'
|
||||
EVENT_MQTT_MESSAGE_RECEIVED = 'MQTT_MESSAGE_RECEIVED'
|
||||
|
||||
DEPENDENCIES = []
|
||||
REQUIREMENTS = ['paho-mqtt==1.1']
|
||||
|
||||
CONF_BROKER = 'broker'
|
||||
CONF_PORT = 'port'
|
||||
CONF_CLIENT_ID = 'client_id'
|
||||
CONF_KEEPALIVE = 'keepalive'
|
||||
CONF_USERNAME = 'username'
|
||||
CONF_PASSWORD = 'password'
|
||||
|
||||
ATTR_TOPIC = 'topic'
|
||||
ATTR_PAYLOAD = 'payload'
|
||||
ATTR_QOS = 'qos'
|
||||
|
||||
|
||||
def publish(hass, topic, payload, qos=0):
|
||||
""" Send an MQTT message. """
|
||||
data = {
|
||||
ATTR_TOPIC: topic,
|
||||
ATTR_PAYLOAD: payload,
|
||||
ATTR_QOS: qos,
|
||||
}
|
||||
hass.services.call(DOMAIN, SERVICE_PUBLISH, data)
|
||||
|
||||
|
||||
def subscribe(hass, topic, callback, qos=0):
|
||||
""" Subscribe to a topic. """
|
||||
def mqtt_topic_subscriber(event):
|
||||
""" Match subscribed MQTT topic. """
|
||||
if _match_topic(topic, event.data[ATTR_TOPIC]):
|
||||
callback(event.data[ATTR_TOPIC], event.data[ATTR_PAYLOAD],
|
||||
event.data[ATTR_QOS])
|
||||
|
||||
hass.bus.listen(EVENT_MQTT_MESSAGE_RECEIVED, mqtt_topic_subscriber)
|
||||
|
||||
if topic not in MQTT_CLIENT.topics:
|
||||
MQTT_CLIENT.subscribe(topic, qos)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Get the MQTT protocol service. """
|
||||
|
||||
if not validate_config(config, {DOMAIN: ['broker']}, _LOGGER):
|
||||
return False
|
||||
|
||||
conf = config[DOMAIN]
|
||||
|
||||
broker = conf[CONF_BROKER]
|
||||
port = util.convert(conf.get(CONF_PORT), int, DEFAULT_PORT)
|
||||
client_id = util.convert(conf.get(CONF_CLIENT_ID), str)
|
||||
keepalive = util.convert(conf.get(CONF_KEEPALIVE), int, DEFAULT_KEEPALIVE)
|
||||
username = util.convert(conf.get(CONF_USERNAME), str)
|
||||
password = util.convert(conf.get(CONF_PASSWORD), str)
|
||||
|
||||
global MQTT_CLIENT
|
||||
try:
|
||||
MQTT_CLIENT = MQTT(hass, broker, port, client_id, keepalive, username,
|
||||
password)
|
||||
except socket.error:
|
||||
_LOGGER.exception("Can't connect to the broker. "
|
||||
"Please check your settings and the broker "
|
||||
"itself.")
|
||||
return False
|
||||
|
||||
def stop_mqtt(event):
|
||||
""" Stop MQTT component. """
|
||||
MQTT_CLIENT.stop()
|
||||
|
||||
def start_mqtt(event):
|
||||
""" Launch MQTT component when Home Assistant starts up. """
|
||||
MQTT_CLIENT.start()
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_mqtt)
|
||||
|
||||
def publish_service(call):
|
||||
""" Handle MQTT publish service calls. """
|
||||
msg_topic = call.data.get(ATTR_TOPIC)
|
||||
payload = call.data.get(ATTR_PAYLOAD)
|
||||
qos = call.data.get(ATTR_QOS)
|
||||
if msg_topic is None or payload is None:
|
||||
return
|
||||
MQTT_CLIENT.publish(msg_topic, payload, qos)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_mqtt)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_PUBLISH, publish_service)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# This is based on one of the paho-mqtt examples:
|
||||
# http://git.eclipse.org/c/paho/org.eclipse.paho.mqtt.python.git/tree/examples/sub-class.py
|
||||
# pylint: disable=too-many-arguments
|
||||
class MQTT(object): # pragma: no cover
|
||||
""" Implements messaging service for MQTT. """
|
||||
def __init__(self, hass, broker, port, client_id, keepalive, username,
|
||||
password):
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
self.hass = hass
|
||||
self._progress = {}
|
||||
self.topics = {}
|
||||
|
||||
if client_id is None:
|
||||
self._mqttc = mqtt.Client()
|
||||
else:
|
||||
self._mqttc = mqtt.Client(client_id)
|
||||
if username is not None:
|
||||
self._mqttc.username_pw_set(username, password)
|
||||
self._mqttc.on_subscribe = self._mqtt_on_subscribe
|
||||
self._mqttc.on_unsubscribe = self._mqtt_on_unsubscribe
|
||||
self._mqttc.on_connect = self._mqtt_on_connect
|
||||
self._mqttc.on_message = self._mqtt_on_message
|
||||
self._mqttc.connect(broker, port, keepalive)
|
||||
|
||||
def publish(self, topic, payload, qos):
|
||||
""" Publish a MQTT message. """
|
||||
self._mqttc.publish(topic, payload, qos)
|
||||
|
||||
def unsubscribe(self, topic):
|
||||
""" Unsubscribe from topic. """
|
||||
result, mid = self._mqttc.unsubscribe(topic)
|
||||
_raise_on_error(result)
|
||||
self._progress[mid] = topic
|
||||
|
||||
def start(self):
|
||||
""" Run the MQTT client. """
|
||||
self._mqttc.loop_start()
|
||||
|
||||
def stop(self):
|
||||
""" Stop the MQTT client. """
|
||||
self._mqttc.loop_stop()
|
||||
|
||||
def subscribe(self, topic, qos):
|
||||
""" Subscribe to a topic. """
|
||||
if topic in self.topics:
|
||||
return
|
||||
result, mid = self._mqttc.subscribe(topic, qos)
|
||||
_raise_on_error(result)
|
||||
self._progress[mid] = topic
|
||||
self.topics[topic] = None
|
||||
|
||||
def _mqtt_on_connect(self, mqttc, obj, flags, result_code):
|
||||
""" On connect, resubscribe to all topics we were subscribed to. """
|
||||
old_topics = self.topics
|
||||
self._progress = {}
|
||||
self.topics = {}
|
||||
for topic, qos in old_topics.items():
|
||||
# qos is None if we were in process of subscribing
|
||||
if qos is not None:
|
||||
self._mqttc.subscribe(topic, qos)
|
||||
|
||||
def _mqtt_on_subscribe(self, mqttc, obj, mid, granted_qos):
|
||||
""" Called when subscribe succesfull. """
|
||||
topic = self._progress.pop(mid, None)
|
||||
if topic is None:
|
||||
return
|
||||
self.topics[topic] = granted_qos
|
||||
|
||||
def _mqtt_on_unsubscribe(self, mqttc, obj, mid, granted_qos):
|
||||
""" Called when subscribe succesfull. """
|
||||
topic = self._progress.pop(mid, None)
|
||||
if topic is None:
|
||||
return
|
||||
self.topics.pop(topic, None)
|
||||
|
||||
def _mqtt_on_message(self, mqttc, obj, msg):
|
||||
""" Message callback """
|
||||
self.hass.bus.fire(EVENT_MQTT_MESSAGE_RECEIVED, {
|
||||
ATTR_TOPIC: msg.topic,
|
||||
ATTR_QOS: msg.qos,
|
||||
ATTR_PAYLOAD: msg.payload.decode('utf-8'),
|
||||
})
|
||||
|
||||
|
||||
def _raise_on_error(result): # pragma: no cover
|
||||
""" Raise error if error result. """
|
||||
if result != 0:
|
||||
raise HomeAssistantError('Error talking to MQTT: {}'.format(result))
|
||||
|
||||
|
||||
def _match_topic(subscription, topic):
|
||||
""" Returns if topic matches subscription. """
|
||||
if subscription.endswith('#'):
|
||||
return (subscription[:-2] == topic or
|
||||
topic.startswith(subscription[:-1]))
|
||||
|
||||
sub_parts = subscription.split('/')
|
||||
topic_parts = topic.split('/')
|
||||
|
||||
return (len(sub_parts) == len(topic_parts) and
|
||||
all(a == b for a, b in zip(sub_parts, topic_parts) if a != '+'))
|
||||
87
homeassistant/components/notify/__init__.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
homeassistant.components.notify
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Provides functionality to notify people.
|
||||
"""
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.helpers import config_per_platform
|
||||
|
||||
from homeassistant.const import CONF_NAME
|
||||
|
||||
DOMAIN = "notify"
|
||||
DEPENDENCIES = []
|
||||
|
||||
# Title of notification
|
||||
ATTR_TITLE = "title"
|
||||
ATTR_TITLE_DEFAULT = "Home Assistant"
|
||||
|
||||
# Text to notify user of
|
||||
ATTR_MESSAGE = "message"
|
||||
|
||||
SERVICE_NOTIFY = "notify"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_message(hass, message):
|
||||
""" Send a notification message. """
|
||||
hass.services.call(DOMAIN, SERVICE_NOTIFY, {ATTR_MESSAGE: message})
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Sets up notify services. """
|
||||
success = False
|
||||
|
||||
for platform, p_config in config_per_platform(config, DOMAIN, _LOGGER):
|
||||
# get platform
|
||||
notify_implementation = get_component(
|
||||
'notify.{}'.format(platform))
|
||||
|
||||
if notify_implementation is None:
|
||||
_LOGGER.error("Unknown notification service specified.")
|
||||
continue
|
||||
|
||||
# create platform service
|
||||
notify_service = notify_implementation.get_service(
|
||||
hass, {DOMAIN: p_config})
|
||||
|
||||
if notify_service is None:
|
||||
_LOGGER.error("Failed to initialize notification service %s",
|
||||
platform)
|
||||
continue
|
||||
|
||||
# create service handler
|
||||
def notify_message(notify_service, call):
|
||||
""" Handle sending notification message service calls. """
|
||||
message = call.data.get(ATTR_MESSAGE)
|
||||
|
||||
if message is None:
|
||||
return
|
||||
|
||||
title = call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
|
||||
|
||||
notify_service.send_message(message, title=title)
|
||||
|
||||
# register service
|
||||
service_call_handler = partial(notify_message, notify_service)
|
||||
service_notify = p_config.get(CONF_NAME, SERVICE_NOTIFY)
|
||||
hass.services.register(DOMAIN, service_notify, service_call_handler)
|
||||
success = True
|
||||
|
||||
return success
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class BaseNotificationService(object):
|
||||
""" Provides an ABC for notification services. """
|
||||
|
||||
def send_message(self, message, **kwargs):
|
||||
"""
|
||||
Send a message.
|
||||
kwargs can contain ATTR_TITLE to specify a title.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
31
homeassistant/components/notify/demo.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
homeassistant.components.notify.demo
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Demo notification service.
|
||||
"""
|
||||
from homeassistant.components.notify import ATTR_TITLE, BaseNotificationService
|
||||
|
||||
|
||||
EVENT_NOTIFY = "notify"
|
||||
|
||||
|
||||
def get_service(hass, config):
|
||||
""" Get the demo notification service. """
|
||||
|
||||
return DemoNotificationService(hass)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class DemoNotificationService(BaseNotificationService):
|
||||
""" Implements demo notification service. """
|
||||
|
||||
def __init__(self, hass):
|
||||
self.hass = hass
|
||||
|
||||
def send_message(self, message="", **kwargs):
|
||||
""" Send a message to a user. """
|
||||
|
||||
title = kwargs.get(ATTR_TITLE)
|
||||
|
||||
self.hass.bus.fire(EVENT_NOTIFY, {"title": title, "message": message})
|
||||
77
homeassistant/components/notify/file.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
homeassistant.components.notify.file
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
File notification service.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the File notifier you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
notify:
|
||||
platform: file
|
||||
filename: FILENAME
|
||||
timestamp: 1 or 0
|
||||
|
||||
Variables:
|
||||
|
||||
filename
|
||||
*Required
|
||||
Name of the file to use. The file will be created if it doesn't exist and saved
|
||||
in your config/ folder.
|
||||
|
||||
timestamp
|
||||
*Required
|
||||
Add a timestamp to the entry, valid entries are 1 or 0.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.notify import (
|
||||
DOMAIN, ATTR_TITLE, BaseNotificationService)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_service(hass, config):
|
||||
""" Get the file notification service. """
|
||||
|
||||
if not validate_config(config,
|
||||
{DOMAIN: ['filename',
|
||||
'timestamp']},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
filename = config[DOMAIN]['filename']
|
||||
timestamp = config[DOMAIN]['timestamp']
|
||||
|
||||
return FileNotificationService(hass, filename, timestamp)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class FileNotificationService(BaseNotificationService):
|
||||
""" Implements notification service for the File service. """
|
||||
|
||||
def __init__(self, hass, filename, add_timestamp):
|
||||
self.filepath = os.path.join(hass.config.config_dir, filename)
|
||||
self.add_timestamp = add_timestamp
|
||||
|
||||
def send_message(self, message="", **kwargs):
|
||||
""" Send a message to a file. """
|
||||
|
||||
with open(self.filepath, 'a') as file:
|
||||
if os.stat(self.filepath).st_size == 0:
|
||||
title = '{} notifications (Log started: {})\n{}\n'.format(
|
||||
kwargs.get(ATTR_TITLE),
|
||||
dt_util.strip_microseconds(dt_util.utcnow()),
|
||||
'-'*80)
|
||||
file.write(title)
|
||||
|
||||
if self.add_timestamp == 1:
|
||||
text = '{} {}\n'.format(dt_util.utcnow(), message)
|
||||
file.write(text)
|
||||
else:
|
||||
text = '{}\n'.format(message)
|
||||
file.write(text)
|
||||
159
homeassistant/components/notify/instapush.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
homeassistant.components.notify.instapush
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Instapush notification service.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the Instapush notifier you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
notify:
|
||||
platform: instapush
|
||||
api_key: YOUR_APP_KEY
|
||||
app_secret: YOUR_APP_SECRET
|
||||
event: YOUR_EVENT
|
||||
tracker: YOUR_TRACKER
|
||||
|
||||
Variables:
|
||||
|
||||
api_key
|
||||
*Required
|
||||
To retrieve this value log into your account at https://instapush.im and go
|
||||
to 'APPS', choose an app, and check 'Basic Info'.
|
||||
|
||||
app_secret
|
||||
*Required
|
||||
To get this value log into your account at https://instapush.im and go to
|
||||
'APPS'. The 'Application ID' can be found under 'Basic Info'.
|
||||
|
||||
event
|
||||
*Required
|
||||
To retrieve a valid event log into your account at https://instapush.im and go
|
||||
to 'APPS'. If you have no events to use with Home Assistant, create one event
|
||||
for your app.
|
||||
|
||||
tracker
|
||||
*Required
|
||||
To retrieve the tracker value log into your account at https://instapush.im and
|
||||
go to 'APPS', choose the app, and check the event entries.
|
||||
|
||||
Example usage of Instapush if you have an event 'notification' and a tracker
|
||||
'home-assistant'.
|
||||
|
||||
curl -X POST \
|
||||
-H "x-instapush-appid: YOUR_APP_KEY" \
|
||||
-H "x-instapush-appsecret: YOUR_APP_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"notification","trackers":{"home-assistant":"Switch 1"}}' \
|
||||
https://api.instapush.im/v1/post
|
||||
|
||||
Details for the API : https://instapush.im/developer/rest
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.notify import (
|
||||
DOMAIN, ATTR_TITLE, BaseNotificationService)
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_RESOURCE = 'https://api.instapush.im/v1/'
|
||||
|
||||
|
||||
def get_service(hass, config):
|
||||
""" Get the instapush notification service. """
|
||||
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_API_KEY,
|
||||
'app_secret',
|
||||
'event',
|
||||
'tracker']},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
except ImportError:
|
||||
_LOGGER.exception(
|
||||
"Unable to import requests. "
|
||||
"Did you maybe not install the 'Requests' package?")
|
||||
|
||||
return None
|
||||
|
||||
# pylint: disable=unused-variable
|
||||
try:
|
||||
response = requests.get(_RESOURCE)
|
||||
|
||||
except requests.ConnectionError:
|
||||
_LOGGER.error(
|
||||
"Connection error "
|
||||
"Please check if https://instapush.im is available.")
|
||||
|
||||
return None
|
||||
|
||||
instapush = requests.Session()
|
||||
headers = {'x-instapush-appid': config[DOMAIN][CONF_API_KEY],
|
||||
'x-instapush-appsecret': config[DOMAIN]['app_secret']}
|
||||
response = instapush.get(_RESOURCE + 'events/list',
|
||||
headers=headers)
|
||||
|
||||
try:
|
||||
if response.json()['error']:
|
||||
_LOGGER.error(response.json()['msg'])
|
||||
# pylint: disable=bare-except
|
||||
except:
|
||||
try:
|
||||
next(events for events in response.json()
|
||||
if events['title'] == config[DOMAIN]['event'])
|
||||
except StopIteration:
|
||||
_LOGGER.error(
|
||||
"No event match your given value. "
|
||||
"Please create an event at https://instapush.im")
|
||||
else:
|
||||
return InstapushNotificationService(
|
||||
config[DOMAIN].get(CONF_API_KEY),
|
||||
config[DOMAIN]['app_secret'],
|
||||
config[DOMAIN]['event'],
|
||||
config[DOMAIN]['tracker']
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class InstapushNotificationService(BaseNotificationService):
|
||||
""" Implements notification service for Instapush. """
|
||||
|
||||
def __init__(self, api_key, app_secret, event, tracker):
|
||||
# pylint: disable=no-name-in-module, unused-variable
|
||||
from requests import Session
|
||||
|
||||
self._api_key = api_key
|
||||
self._app_secret = app_secret
|
||||
self._event = event
|
||||
self._tracker = tracker
|
||||
self._headers = {
|
||||
'x-instapush-appid': self._api_key,
|
||||
'x-instapush-appsecret': self._app_secret,
|
||||
'Content-Type': 'application/json'}
|
||||
|
||||
self.instapush = Session()
|
||||
|
||||
def send_message(self, message="", **kwargs):
|
||||
""" Send a message to a user. """
|
||||
|
||||
title = kwargs.get(ATTR_TITLE)
|
||||
|
||||
data = {"event": self._event,
|
||||
"trackers": {self._tracker: title + " : " + message}}
|
||||
|
||||
response = self.instapush.post(
|
||||
_RESOURCE + 'post',
|
||||
data=json.dumps(data),
|
||||
headers=self._headers)
|
||||
|
||||
if response.json()['status'] == 401:
|
||||
_LOGGER.error(
|
||||
response.json()['msg'],
|
||||
"Please check your details at https://instapush.im/")
|
||||
95
homeassistant/components/notify/nma.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
homeassistant.components.notify.nma
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
NMA (Notify My Android) notification service.
|
||||
|
||||
Configuration:
|
||||
|
||||
To use the NMA notifier you will need to add something like the following
|
||||
to your configuration.yaml file.
|
||||
|
||||
notify:
|
||||
platform: nma
|
||||
api_key: YOUR_API_KEY
|
||||
|
||||
Variables:
|
||||
|
||||
api_key
|
||||
*Required
|
||||
Enter the API key for NMA. Go to https://www.notifymyandroid.com and create a
|
||||
new API key to use with Home Assistant.
|
||||
|
||||
Details for the API : https://www.notifymyandroid.com/api.jsp
|
||||
"""
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.components.notify import (
|
||||
DOMAIN, ATTR_TITLE, BaseNotificationService)
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_RESOURCE = 'https://www.notifymyandroid.com/publicapi/'
|
||||
|
||||
|
||||
def get_service(hass, config):
|
||||
""" Get the NMA notification service. """
|
||||
|
||||
if not validate_config(config,
|
||||
{DOMAIN: [CONF_API_KEY]},
|
||||
_LOGGER):
|
||||
return None
|
||||
|
||||
try:
|
||||
# pylint: disable=unused-variable
|
||||
from requests import Session
|
||||
|
||||
except ImportError:
|
||||
_LOGGER.exception(
|
||||
"Unable to import requests. "
|
||||
"Did you maybe not install the 'Requests' package?")
|
||||
|
||||
return None
|
||||
|
||||
nma = Session()
|
||||
response = nma.get(_RESOURCE + 'verify',
|
||||
params={"apikey": config[DOMAIN][CONF_API_KEY]})
|
||||
tree = ET.fromstring(response.content)
|
||||
|
||||
if tree[0].tag == 'error':
|
||||
_LOGGER.error("Wrong API key supplied. %s", tree[0].text)
|
||||
else:
|
||||
return NmaNotificationService(config[DOMAIN][CONF_API_KEY])
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class NmaNotificationService(BaseNotificationService):
|
||||
""" Implements notification service for NMA. """
|
||||
|
||||
def __init__(self, api_key):
|
||||
# pylint: disable=no-name-in-module, unused-variable
|
||||
from requests import Session
|
||||
|
||||
self._api_key = api_key
|
||||
self._data = {"apikey": self._api_key}
|
||||
|
||||
self.nma = Session()
|
||||
|
||||
def send_message(self, message="", **kwargs):
|
||||
""" Send a message to a user. """
|
||||
|
||||
title = kwargs.get(ATTR_TITLE)
|
||||
|
||||
self._data['application'] = 'home-assistant'
|
||||
self._data['event'] = title
|
||||
self._data['description'] = message
|
||||
self._data['priority'] = 0
|
||||
|
||||
response = self.nma.get(_RESOURCE + 'notify',
|
||||
params=self._data)
|
||||
tree = ET.fromstring(response.content)
|
||||
|
||||
if tree[0].tag == 'error':
|
||||
_LOGGER.exception(
|
||||
"Unable to perform request. Error: %s", tree[0].text)
|
||||