mirror of
https://github.com/home-assistant/core.git
synced 2026-01-09 17:17:17 +01:00
Compare commits
392 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d814d40330 | ||
|
|
547e089185 | ||
|
|
fe2e0c44c8 | ||
|
|
30bd92c851 | ||
|
|
78afbd4292 | ||
|
|
8f8772093d | ||
|
|
210bbc53a4 | ||
|
|
ce0537ef7f | ||
|
|
73cd902857 | ||
|
|
aae9697d9a | ||
|
|
af3d9d8245 | ||
|
|
640729f312 | ||
|
|
37bb626dd2 | ||
|
|
21273de6a1 | ||
|
|
fe271749c2 | ||
|
|
af0253b2eb | ||
|
|
986bcfef21 | ||
|
|
96f19c7205 | ||
|
|
cdc2df012c | ||
|
|
8dd790e745 | ||
|
|
e90e94b667 | ||
|
|
52f40b3370 | ||
|
|
8ed75217e1 | ||
|
|
1e92417804 | ||
|
|
be9cdf51d9 | ||
|
|
0e1a3c0665 | ||
|
|
b77df372d6 | ||
|
|
8c525b3087 | ||
|
|
5359001c04 | ||
|
|
2481cd2012 | ||
|
|
e4ddb00086 | ||
|
|
f9a019ea82 | ||
|
|
417240ee3e | ||
|
|
ffc2541ba5 | ||
|
|
d74dbc35f2 | ||
|
|
1c2224cc5c | ||
|
|
56c66a19f0 | ||
|
|
79da44a6b3 | ||
|
|
d9805160bc | ||
|
|
f463f4d8c6 | ||
|
|
4da8ec0a05 | ||
|
|
fb34f94d9c | ||
|
|
513c2b03c9 | ||
|
|
4dc9ac820f | ||
|
|
619d329a16 | ||
|
|
e2c6f538a8 | ||
|
|
8739991676 | ||
|
|
26b097b860 | ||
|
|
4e8723f345 | ||
|
|
85f30b893e | ||
|
|
6cadb796bc | ||
|
|
9eaa057739 | ||
|
|
b6324b511c | ||
|
|
80a9539f97 | ||
|
|
8c266f9266 | ||
|
|
5043b85c58 | ||
|
|
890c11cc7c | ||
|
|
253c8aee1f | ||
|
|
12e1602a81 | ||
|
|
25a25dde7a | ||
|
|
c0eaf0386c | ||
|
|
6b96bc3859 | ||
|
|
6d94c121a7 | ||
|
|
ae34640a80 | ||
|
|
8832de80bc | ||
|
|
ed3f7d1581 | ||
|
|
062fb7ac4c | ||
|
|
cc293db5ab | ||
|
|
c9c102815a | ||
|
|
5d23afdc9e | ||
|
|
e95b48ca44 | ||
|
|
646c03eea1 | ||
|
|
05ece53ec2 | ||
|
|
b5214af762 | ||
|
|
3630dc7ff3 | ||
|
|
e7fc8a1890 | ||
|
|
2891b0cb2e | ||
|
|
fc44a4ed99 | ||
|
|
444b7c5ee7 | ||
|
|
690760404b | ||
|
|
6a9968ccb9 | ||
|
|
e91ed1f2a4 | ||
|
|
115c59d88c | ||
|
|
97bb252d23 | ||
|
|
20a1a52bd5 | ||
|
|
9e27e05a84 | ||
|
|
67c48736a2 | ||
|
|
35805e51a3 | ||
|
|
6057b41151 | ||
|
|
2374659984 | ||
|
|
2c3195522f | ||
|
|
b3e88d1f8f | ||
|
|
dd7d8d56bb | ||
|
|
df19172e56 | ||
|
|
f060dcc0aa | ||
|
|
5c168ab551 | ||
|
|
fe9b45c964 | ||
|
|
38c189ecf4 | ||
|
|
8e4f0ea5ae | ||
|
|
248d974ded | ||
|
|
2d93285689 | ||
|
|
68390373e5 | ||
|
|
c0f8e6c5c5 | ||
|
|
85d7377beb | ||
|
|
f17cf1d26b | ||
|
|
e50b59a56c | ||
|
|
e43fefa8f6 | ||
|
|
e819678e27 | ||
|
|
9df7302603 | ||
|
|
afe88dfa0f | ||
|
|
027ce2f555 | ||
|
|
63a10233c5 | ||
|
|
acbf45d5f8 | ||
|
|
d13f3eca92 | ||
|
|
fc291dd5ab | ||
|
|
583e57042b | ||
|
|
9d0c2a8dae | ||
|
|
c2ef22bd08 | ||
|
|
2561efe45d | ||
|
|
c191c13f3a | ||
|
|
b1291e572e | ||
|
|
d1416056cd | ||
|
|
7987065ad7 | ||
|
|
fc8940111d | ||
|
|
63c9d59d54 | ||
|
|
61ccbb59ce | ||
|
|
5fabfced38 | ||
|
|
6c39e1ef19 | ||
|
|
632466bb56 | ||
|
|
7784c40f12 | ||
|
|
41fa8cc8f2 | ||
|
|
2a2a106e62 | ||
|
|
45e140149b | ||
|
|
34368a6b69 | ||
|
|
e8f5445acc | ||
|
|
00b9297082 | ||
|
|
2bdad5388b | ||
|
|
29fb65b224 | ||
|
|
560a4ef5eb | ||
|
|
186f8f6996 | ||
|
|
238884dfe2 | ||
|
|
6da08deabf | ||
|
|
e970edbf20 | ||
|
|
7c69941f13 | ||
|
|
179655b6b0 | ||
|
|
6ebff3cda4 | ||
|
|
70eaa5f10e | ||
|
|
485e81db79 | ||
|
|
fc2f41fe8a | ||
|
|
a4b0e8f897 | ||
|
|
3cf99e29be | ||
|
|
5f8eb08cd9 | ||
|
|
d1424714c7 | ||
|
|
74e93e5853 | ||
|
|
bd72f45788 | ||
|
|
845fd532f0 | ||
|
|
46404a84ec | ||
|
|
ebce666264 | ||
|
|
15cf34f45f | ||
|
|
e620479cc8 | ||
|
|
b292a4af3f | ||
|
|
79d71c6727 | ||
|
|
0b850b555f | ||
|
|
176c99f0cd | ||
|
|
42e59b465e | ||
|
|
e8a701ffd0 | ||
|
|
32f58baa85 | ||
|
|
9794336113 | ||
|
|
ed82f23da3 | ||
|
|
48c86e07fa | ||
|
|
76a0763cbc | ||
|
|
f4f36a3662 | ||
|
|
e201bcad14 | ||
|
|
5182f76aea | ||
|
|
53b1c75d81 | ||
|
|
fdc769abf7 | ||
|
|
f57e307c7a | ||
|
|
f9d89a016e | ||
|
|
205f24c070 | ||
|
|
4bf1972393 | ||
|
|
4fa0119245 | ||
|
|
ccde371a9d | ||
|
|
4e7cc110d9 | ||
|
|
05ba78d886 | ||
|
|
ee56e33193 | ||
|
|
106bf467f8 | ||
|
|
56cbfb5f2a | ||
|
|
193188b965 | ||
|
|
4197c9ee85 | ||
|
|
9418c61b25 | ||
|
|
62caea6bfb | ||
|
|
80053ef21b | ||
|
|
bd4304e838 | ||
|
|
c08c8c7996 | ||
|
|
9d39a5ced3 | ||
|
|
816b69c807 | ||
|
|
9f62d5e3cf | ||
|
|
796a3ff49d | ||
|
|
089e1ab6f4 | ||
|
|
5ad715507b | ||
|
|
ead4e44cd6 | ||
|
|
2a4c5466ef | ||
|
|
2ab14bbabc | ||
|
|
28b7a3da32 | ||
|
|
bf26b75d27 | ||
|
|
3ea4691fce | ||
|
|
f27ad76230 | ||
|
|
60053a642c | ||
|
|
d9f5398c56 | ||
|
|
5df985a510 | ||
|
|
789929d445 | ||
|
|
51a65ee8e9 | ||
|
|
222cc4c393 | ||
|
|
ce1a2cc2a6 | ||
|
|
aab7442cc5 | ||
|
|
4f1eab138c | ||
|
|
53df3fadd7 | ||
|
|
41c2bdb4fb | ||
|
|
d16c5f9046 | ||
|
|
29d4dca56a | ||
|
|
9722125234 | ||
|
|
78c302855a | ||
|
|
c1b197419d | ||
|
|
6cce934f72 | ||
|
|
38cb32afd6 | ||
|
|
c96c283293 | ||
|
|
2fb4709a94 | ||
|
|
42f450d4e6 | ||
|
|
6ea866c7f7 | ||
|
|
429b637885 | ||
|
|
f05a8bfa2a | ||
|
|
96e3dfeb53 | ||
|
|
520de0d278 | ||
|
|
2cacfb5477 | ||
|
|
4960892256 | ||
|
|
834d0e489e | ||
|
|
1e1d593ef7 | ||
|
|
8a93cc147a | ||
|
|
628b9bd8d8 | ||
|
|
1bec2c005d | ||
|
|
587948ec06 | ||
|
|
f641a6aad3 | ||
|
|
a1d5daee53 | ||
|
|
8a2134b3a8 | ||
|
|
c06d92900a | ||
|
|
a628112e4c | ||
|
|
6e0efbe35e | ||
|
|
778761ebce | ||
|
|
76a3a4892d | ||
|
|
bef4ae3e35 | ||
|
|
818a52508e | ||
|
|
02f8779de8 | ||
|
|
33f8ca5abc | ||
|
|
3700fce859 | ||
|
|
9d20a53d63 | ||
|
|
1d68777981 | ||
|
|
f5b305c980 | ||
|
|
382f9a8f49 | ||
|
|
778c3bb83d | ||
|
|
e57d0f345e | ||
|
|
ed70fc9322 | ||
|
|
82c7195484 | ||
|
|
9be7763144 | ||
|
|
875edef3f0 | ||
|
|
3de95c068a | ||
|
|
51c5534c2a | ||
|
|
d95b75a10c | ||
|
|
5986d9ff5b | ||
|
|
eb6fb5549f | ||
|
|
7596ac23fc | ||
|
|
c37883c9a9 | ||
|
|
b1dc48822d | ||
|
|
da8be253bc | ||
|
|
959a7b2d59 | ||
|
|
ac256d5943 | ||
|
|
0362a76cd6 | ||
|
|
26cb67dec2 | ||
|
|
00244380a8 | ||
|
|
f807a3a890 | ||
|
|
fd6c2598a7 | ||
|
|
79d1a0ab37 | ||
|
|
a787ab6d3c | ||
|
|
8456cd0313 | ||
|
|
fa37d9800e | ||
|
|
80826bc985 | ||
|
|
b00d0a1253 | ||
|
|
f7545fe85c | ||
|
|
c69e9c1d49 | ||
|
|
79b029a680 | ||
|
|
9891320e7c | ||
|
|
64853bae32 | ||
|
|
a7f4bcc410 | ||
|
|
bbb406626b | ||
|
|
c5c594ba7d | ||
|
|
8d83912649 | ||
|
|
2c1f0f3449 | ||
|
|
c85b5561ee | ||
|
|
4cf300a710 | ||
|
|
3bdb7052b8 | ||
|
|
3b5a9e7796 | ||
|
|
5fcb0990c3 | ||
|
|
be5c0b2d92 | ||
|
|
38e02a057d | ||
|
|
fad9e607c3 | ||
|
|
c33b179fb8 | ||
|
|
765560e87a | ||
|
|
f837302194 | ||
|
|
47d8601f30 | ||
|
|
bddb424b0d | ||
|
|
8db4b4f303 | ||
|
|
cc4ec228b5 | ||
|
|
c6e6496000 | ||
|
|
2c9010d661 | ||
|
|
24826c2770 | ||
|
|
c1aaed250a | ||
|
|
59fcef39ff | ||
|
|
d0ff45500b | ||
|
|
0ace832166 | ||
|
|
19887f8742 | ||
|
|
7f97d166bf | ||
|
|
0de2266a72 | ||
|
|
8f06b35dfc | ||
|
|
a97e7bb22d | ||
|
|
fc47e9443b | ||
|
|
e144b0f0f9 | ||
|
|
a024c1b162 | ||
|
|
581e2f22d5 | ||
|
|
5232f2abdd | ||
|
|
cb52b80f7d | ||
|
|
d0ec9301ab | ||
|
|
9abd0fb92f | ||
|
|
43d77729c5 | ||
|
|
04b3c89cf5 | ||
|
|
09e2075c68 | ||
|
|
3bd9684ca5 | ||
|
|
414900fefb | ||
|
|
35484ca086 | ||
|
|
603765fe92 | ||
|
|
80140732c3 | ||
|
|
c00647ace0 | ||
|
|
2a2ee81957 | ||
|
|
b620c433c0 | ||
|
|
b80f00900d | ||
|
|
a32fc10f1b | ||
|
|
672ff96754 | ||
|
|
8132989f91 | ||
|
|
ca54bbfcc9 | ||
|
|
e19e9a1f2b | ||
|
|
e89e64263c | ||
|
|
f56bdd29ff | ||
|
|
9eff9fa703 | ||
|
|
c1f156fd2b | ||
|
|
4342d7aa17 | ||
|
|
af3ea5a321 | ||
|
|
c09b7b5d6d | ||
|
|
710454119f | ||
|
|
25e6d694e1 | ||
|
|
19a20b3b13 | ||
|
|
bd5b70c3cd | ||
|
|
ec5439e4d4 | ||
|
|
fd509e188a | ||
|
|
a5a839e72a | ||
|
|
3b53952dbe | ||
|
|
e502202de7 | ||
|
|
2479ce9123 | ||
|
|
d3772d4abd | ||
|
|
e9f36a7e45 | ||
|
|
f036bf9353 | ||
|
|
f4679cc870 | ||
|
|
7b116b0207 | ||
|
|
ffb19381f1 | ||
|
|
1525cbfb93 | ||
|
|
b83059c828 | ||
|
|
c7226ec28f | ||
|
|
c95c8a04ef | ||
|
|
d2d28fd419 | ||
|
|
67007aed40 | ||
|
|
df1c3dfb67 | ||
|
|
689484216d | ||
|
|
51c6029fe5 | ||
|
|
0eee544d17 | ||
|
|
21cca21124 | ||
|
|
f837451633 | ||
|
|
cce4a569e4 | ||
|
|
4feea9d7ec | ||
|
|
7aff588bf0 | ||
|
|
41a046a69d | ||
|
|
e548bd5312 | ||
|
|
88098283c7 | ||
|
|
6c6ed29329 | ||
|
|
c4f4e492e5 | ||
|
|
c286e2c434 |
40
.coveragerc
40
.coveragerc
@@ -71,6 +71,9 @@ omit =
|
||||
homeassistant/components/envisalink.py
|
||||
homeassistant/components/*/envisalink.py
|
||||
|
||||
homeassistant/components/gc100.py
|
||||
homeassistant/components/*/gc100.py
|
||||
|
||||
homeassistant/components/google.py
|
||||
homeassistant/components/*/google.py
|
||||
|
||||
@@ -107,6 +110,9 @@ omit =
|
||||
homeassistant/components/lametric.py
|
||||
homeassistant/components/*/lametric.py
|
||||
|
||||
homeassistant/components/linode.py
|
||||
homeassistant/components/*/linode.py
|
||||
|
||||
homeassistant/components/lutron.py
|
||||
homeassistant/components/*/lutron.py
|
||||
|
||||
@@ -170,6 +176,9 @@ omit =
|
||||
homeassistant/components/scsgate.py
|
||||
homeassistant/components/*/scsgate.py
|
||||
|
||||
homeassistant/components/skybell.py
|
||||
homeassistant/components/*/skybell.py
|
||||
|
||||
homeassistant/components/tado.py
|
||||
homeassistant/components/*/tado.py
|
||||
|
||||
@@ -187,6 +196,9 @@ omit =
|
||||
|
||||
homeassistant/components/*/thinkingcleaner.py
|
||||
|
||||
homeassistant/components/toon.py
|
||||
homeassistant/components/*/toon.py
|
||||
|
||||
homeassistant/components/tradfri.py
|
||||
homeassistant/components/*/tradfri.py
|
||||
|
||||
@@ -217,7 +229,7 @@ omit =
|
||||
homeassistant/components/wemo.py
|
||||
homeassistant/components/*/wemo.py
|
||||
|
||||
homeassistant/components/wink.py
|
||||
homeassistant/components/wink/*
|
||||
homeassistant/components/*/wink.py
|
||||
|
||||
homeassistant/components/xiaomi_aqara.py
|
||||
@@ -266,7 +278,10 @@ omit =
|
||||
homeassistant/components/camera/mjpeg.py
|
||||
homeassistant/components/camera/rpi_camera.py
|
||||
homeassistant/components/camera/onvif.py
|
||||
homeassistant/components/camera/ring.py
|
||||
homeassistant/components/camera/synology.py
|
||||
homeassistant/components/camera/yi.py
|
||||
homeassistant/components/climate/ephember.py
|
||||
homeassistant/components/climate/eq3btsmart.py
|
||||
homeassistant/components/climate/flexit.py
|
||||
homeassistant/components/climate/heatmiser.py
|
||||
@@ -317,6 +332,7 @@ omit =
|
||||
homeassistant/components/emoncms_history.py
|
||||
homeassistant/components/emulated_hue/upnp.py
|
||||
homeassistant/components/fan/mqtt.py
|
||||
homeassistant/components/fan/xiaomi_miio.py
|
||||
homeassistant/components/feedreader.py
|
||||
homeassistant/components/foursquare.py
|
||||
homeassistant/components/ifttt.py
|
||||
@@ -404,8 +420,9 @@ omit =
|
||||
homeassistant/components/notify/aws_sns.py
|
||||
homeassistant/components/notify/aws_sqs.py
|
||||
homeassistant/components/notify/ciscospark.py
|
||||
homeassistant/components/notify/clickatell.py
|
||||
homeassistant/components/notify/clicksend.py
|
||||
homeassistant/components/notify/clicksendaudio.py
|
||||
homeassistant/components/notify/clicksend_tts.py
|
||||
homeassistant/components/notify/discord.py
|
||||
homeassistant/components/notify/facebook.py
|
||||
homeassistant/components/notify/free_mobile.py
|
||||
@@ -427,6 +444,7 @@ omit =
|
||||
homeassistant/components/notify/pushover.py
|
||||
homeassistant/components/notify/pushsafer.py
|
||||
homeassistant/components/notify/rest.py
|
||||
homeassistant/components/notify/rocketchat.py
|
||||
homeassistant/components/notify/sendgrid.py
|
||||
homeassistant/components/notify/simplepush.py
|
||||
homeassistant/components/notify/slack.py
|
||||
@@ -436,8 +454,10 @@ omit =
|
||||
homeassistant/components/notify/telstra.py
|
||||
homeassistant/components/notify/twitter.py
|
||||
homeassistant/components/notify/xmpp.py
|
||||
homeassistant/components/notify/yessssms.py
|
||||
homeassistant/components/nuimo_controller.py
|
||||
homeassistant/components/prometheus.py
|
||||
homeassistant/components/remember_the_milk/__init__.py
|
||||
homeassistant/components/remote/harmony.py
|
||||
homeassistant/components/remote/itach.py
|
||||
homeassistant/components/scene/hunterdouglas_powerview.py
|
||||
@@ -454,7 +474,6 @@ omit =
|
||||
homeassistant/components/sensor/broadlink.py
|
||||
homeassistant/components/sensor/buienradar.py
|
||||
homeassistant/components/sensor/citybikes.py
|
||||
homeassistant/components/sensor/coinmarketcap.py
|
||||
homeassistant/components/sensor/cert_expiry.py
|
||||
homeassistant/components/sensor/comed_hourly_pricing.py
|
||||
homeassistant/components/sensor/cpuspeed.py
|
||||
@@ -462,6 +481,7 @@ omit =
|
||||
homeassistant/components/sensor/cups.py
|
||||
homeassistant/components/sensor/currencylayer.py
|
||||
homeassistant/components/sensor/darksky.py
|
||||
homeassistant/components/sensor/deluge.py
|
||||
homeassistant/components/sensor/deutsche_bahn.py
|
||||
homeassistant/components/sensor/dht.py
|
||||
homeassistant/components/sensor/dnsip.py
|
||||
@@ -489,17 +509,18 @@ omit =
|
||||
homeassistant/components/sensor/gpsd.py
|
||||
homeassistant/components/sensor/gtfs.py
|
||||
homeassistant/components/sensor/haveibeenpwned.py
|
||||
homeassistant/components/sensor/hddtemp.py
|
||||
homeassistant/components/sensor/hp_ilo.py
|
||||
homeassistant/components/sensor/htu21d.py
|
||||
homeassistant/components/sensor/hydroquebec.py
|
||||
homeassistant/components/sensor/imap.py
|
||||
homeassistant/components/sensor/imap_email_content.py
|
||||
homeassistant/components/sensor/influxdb.py
|
||||
homeassistant/components/sensor/irish_rail_transport.py
|
||||
homeassistant/components/sensor/kwb.py
|
||||
homeassistant/components/sensor/lastfm.py
|
||||
homeassistant/components/sensor/linux_battery.py
|
||||
homeassistant/components/sensor/loopenergy.py
|
||||
homeassistant/components/sensor/luftdaten.py
|
||||
homeassistant/components/sensor/lyft.py
|
||||
homeassistant/components/sensor/metoffice.py
|
||||
homeassistant/components/sensor/miflora.py
|
||||
@@ -507,6 +528,7 @@ omit =
|
||||
homeassistant/components/sensor/mopar.py
|
||||
homeassistant/components/sensor/mqtt_room.py
|
||||
homeassistant/components/sensor/mvglive.py
|
||||
homeassistant/components/sensor/nederlandse_spoorwegen.py
|
||||
homeassistant/components/sensor/netdata.py
|
||||
homeassistant/components/sensor/neurio_energy.py
|
||||
homeassistant/components/sensor/nut.py
|
||||
@@ -529,6 +551,7 @@ omit =
|
||||
homeassistant/components/sensor/sabnzbd.py
|
||||
homeassistant/components/sensor/scrape.py
|
||||
homeassistant/components/sensor/sensehat.py
|
||||
homeassistant/components/sensor/serial.py
|
||||
homeassistant/components/sensor/serial_pm.py
|
||||
homeassistant/components/sensor/shodan.py
|
||||
homeassistant/components/sensor/skybeacon.py
|
||||
@@ -542,6 +565,7 @@ omit =
|
||||
homeassistant/components/sensor/swiss_public_transport.py
|
||||
homeassistant/components/sensor/synologydsm.py
|
||||
homeassistant/components/sensor/systemmonitor.py
|
||||
homeassistant/components/sensor/sytadin.py
|
||||
homeassistant/components/sensor/tank_utility.py
|
||||
homeassistant/components/sensor/ted5000.py
|
||||
homeassistant/components/sensor/temper.py
|
||||
@@ -549,12 +573,14 @@ omit =
|
||||
homeassistant/components/sensor/time_date.py
|
||||
homeassistant/components/sensor/torque.py
|
||||
homeassistant/components/sensor/transmission.py
|
||||
homeassistant/components/sensor/travisci.py
|
||||
homeassistant/components/sensor/twitch.py
|
||||
homeassistant/components/sensor/uber.py
|
||||
homeassistant/components/sensor/upnp.py
|
||||
homeassistant/components/sensor/ups.py
|
||||
homeassistant/components/sensor/vasttrafik.py
|
||||
homeassistant/components/sensor/waqi.py
|
||||
homeassistant/components/sensor/whois.py
|
||||
homeassistant/components/sensor/worldtidesinfo.py
|
||||
homeassistant/components/sensor/worxlandroid.py
|
||||
homeassistant/components/sensor/xbox_live.py
|
||||
@@ -566,6 +592,7 @@ omit =
|
||||
homeassistant/components/switch/anel_pwrctrl.py
|
||||
homeassistant/components/switch/arest.py
|
||||
homeassistant/components/switch/broadlink.py
|
||||
homeassistant/components/switch/deluge.py
|
||||
homeassistant/components/switch/digitalloggers.py
|
||||
homeassistant/components/switch/dlink.py
|
||||
homeassistant/components/switch/edimax.py
|
||||
@@ -578,16 +605,19 @@ omit =
|
||||
homeassistant/components/switch/orvibo.py
|
||||
homeassistant/components/switch/pilight.py
|
||||
homeassistant/components/switch/pulseaudio_loopback.py
|
||||
homeassistant/components/switch/rainbird.py
|
||||
homeassistant/components/switch/rainmachine.py
|
||||
homeassistant/components/switch/rest.py
|
||||
homeassistant/components/switch/rpi_rf.py
|
||||
homeassistant/components/switch/snmp.py
|
||||
homeassistant/components/switch/tplink.py
|
||||
homeassistant/components/switch/telnet.py
|
||||
homeassistant/components/switch/transmission.py
|
||||
homeassistant/components/switch/wake_on_lan.py
|
||||
homeassistant/components/switch/xiaomi_miio.py
|
||||
homeassistant/components/telegram_bot/*
|
||||
homeassistant/components/thingspeak.py
|
||||
homeassistant/components/tts/amazon_polly.py
|
||||
homeassistant/components/tts/microsoft.py
|
||||
homeassistant/components/tts/picotts.py
|
||||
homeassistant/components/vacuum/roomba.py
|
||||
homeassistant/components/weather/bom.py
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "homeassistant/components/frontend/www_static/home-assistant-polymer"]
|
||||
path = homeassistant/components/frontend/www_static/home-assistant-polymer
|
||||
url = https://github.com/home-assistant/home-assistant-polymer.git
|
||||
|
||||
32
CODEOWNERS
32
CODEOWNERS
@@ -29,6 +29,9 @@ homeassistant/components/weblink.py @home-assistant/core
|
||||
homeassistant/components/websocket_api.py @home-assistant/core
|
||||
homeassistant/components/zone.py @home-assistant/core
|
||||
|
||||
# To monitor non-pypi additions
|
||||
requirements_all.txt @andrey-git
|
||||
|
||||
Dockerfile @home-assistant/docker
|
||||
virtualization/Docker/* @home-assistant/docker
|
||||
|
||||
@@ -36,10 +39,33 @@ homeassistant/components/zwave/* @home-assistant/z-wave
|
||||
homeassistant/components/*/zwave.py @home-assistant/z-wave
|
||||
|
||||
# Indiviudal components
|
||||
homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt
|
||||
homeassistant/components/camera/yi.py @bachya
|
||||
homeassistant/components/climate/ephember.py @ttroy50
|
||||
homeassistant/components/climate/eq3btsmart.py @rytilahti
|
||||
homeassistant/components/climate/sensibo.py @andrey-git
|
||||
homeassistant/components/cover/template.py @PhracturedBlue
|
||||
homeassistant/components/device_tracker/automatic.py @armills
|
||||
homeassistant/components/media_player/kodi.py @armills
|
||||
homeassistant/components/history_graph.py @andrey-git
|
||||
homeassistant/components/light/tplink.py @rytilahti
|
||||
homeassistant/components/light/yeelight.py @rytilahti
|
||||
homeassistant/components/media_player/kodi.py @armills
|
||||
homeassistant/components/media_player/monoprice.py @etsinko
|
||||
homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth
|
||||
homeassistant/components/sensor/airvisual.py @bachya
|
||||
homeassistant/components/sensor/irish_rail_transport.py @ttroy50
|
||||
homeassistant/components/sensor/miflora.py @danielhiversen
|
||||
homeassistant/components/sensor/sytadin.py @gautric
|
||||
homeassistant/components/sensor/tibber.py @danielhiversen
|
||||
homeassistant/components/sensor/waqi.py @andrey-git
|
||||
homeassistant/components/switch/rainmachine.py @bachya
|
||||
homeassistant/components/switch/tplink.py @rytilahti
|
||||
homeassistant/components/climate/eq3btsmart.py @rytilahti
|
||||
homeassistant/components/*/xiaomi_miio.py @rytilahti
|
||||
homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi
|
||||
|
||||
homeassistant/components/*/broadlink.py @danielhiversen
|
||||
homeassistant/components/*/rfxtrx.py @danielhiversen
|
||||
homeassistant/components/tesla.py @zabuldon
|
||||
homeassistant/components/*/tesla.py @zabuldon
|
||||
homeassistant/components/*/tradfri.py @ggravlingen
|
||||
homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi
|
||||
homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# This way, the development image and the production image are kept in sync.
|
||||
|
||||
FROM python:3.6
|
||||
MAINTAINER Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>
|
||||
LABEL maintainer="Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>"
|
||||
|
||||
# Uncomment any of the following lines to disable the installation.
|
||||
#ENV INSTALL_TELLSTICK no
|
||||
@@ -11,10 +11,8 @@ MAINTAINER Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>
|
||||
#ENV INSTALL_FFMPEG no
|
||||
#ENV INSTALL_LIBCEC no
|
||||
#ENV INSTALL_PHANTOMJS no
|
||||
#ENV INSTALL_COAP no
|
||||
#ENV INSTALL_SSOCR no
|
||||
|
||||
|
||||
VOLUME /config
|
||||
|
||||
RUN mkdir -p /usr/src/app
|
||||
@@ -26,10 +24,10 @@ RUN virtualization/Docker/setup_docker_prereqs
|
||||
|
||||
# Install hass component dependencies
|
||||
COPY requirements_all.txt requirements_all.txt
|
||||
# Uninstall enum34 because some depenndecies install it but breaks Python 3.4+.
|
||||
# Uninstall enum34 because some dependencies install it but breaks Python 3.4+.
|
||||
# See PR #8103 for more info.
|
||||
RUN pip3 install --no-cache-dir -r requirements_all.txt && \
|
||||
pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet
|
||||
pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
include README.rst
|
||||
include LICENSE.md
|
||||
graft homeassistant
|
||||
prune homeassistant/components/frontend/www_static/home-assistant-polymer
|
||||
recursive-exclude * *.py[co]
|
||||
|
||||
BIN
docs/screenshot-components.png
Executable file → Normal file
BIN
docs/screenshot-components.png
Executable file → Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 205 KiB |
@@ -11,13 +11,11 @@ from typing import Any, Optional, Dict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.components as core_components
|
||||
from homeassistant import (
|
||||
core, config as conf_util, loader, components as core_components)
|
||||
from homeassistant.components import persistent_notification
|
||||
import homeassistant.config as conf_util
|
||||
import homeassistant.core as core
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
|
||||
from homeassistant.setup import async_setup_component
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.util.logging import AsyncHandler
|
||||
from homeassistant.util.package import async_get_user_site, get_user_site
|
||||
from homeassistant.util.yaml import clear_secret_cache
|
||||
@@ -90,7 +88,7 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
if sys.version_info[:2] < (3, 5):
|
||||
_LOGGER.warning(
|
||||
'Python 3.4 support has been deprecated and will be removed in '
|
||||
'the begining of 2018. Please upgrade Python or your operating '
|
||||
'the beginning of 2018. Please upgrade Python or your operating '
|
||||
'system. More info: https://home-assistant.io/blog/2017/10/06/'
|
||||
'deprecating-python-3.4-support/'
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ Component design guidelines:
|
||||
import asyncio
|
||||
import itertools as it
|
||||
import logging
|
||||
import os
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.config as conf_util
|
||||
@@ -110,6 +111,11 @@ def async_reload_core_config(hass):
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Set up general services related to Home Assistant."""
|
||||
descriptions = yield from hass.async_add_job(
|
||||
conf_util.load_yaml_config_file, os.path.join(
|
||||
os.path.dirname(__file__), 'services.yaml')
|
||||
)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_turn_service(service):
|
||||
"""Handle calls to homeassistant.turn_on/off."""
|
||||
@@ -149,11 +155,14 @@ def async_setup(hass, config):
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service)
|
||||
ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service,
|
||||
descriptions[ha.DOMAIN][SERVICE_TURN_OFF])
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service)
|
||||
ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service,
|
||||
descriptions[ha.DOMAIN][SERVICE_TURN_ON])
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service)
|
||||
ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service,
|
||||
descriptions[ha.DOMAIN][SERVICE_TOGGLE])
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_core_service(call):
|
||||
@@ -178,11 +187,14 @@ def async_setup(hass, config):
|
||||
hass.async_add_job(hass.async_stop(RESTART_EXIT_CODE))
|
||||
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service)
|
||||
ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service,
|
||||
descriptions[ha.DOMAIN][SERVICE_HOMEASSISTANT_STOP])
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service)
|
||||
ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service,
|
||||
descriptions[ha.DOMAIN][SERVICE_HOMEASSISTANT_RESTART])
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service)
|
||||
ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service,
|
||||
descriptions[ha.DOMAIN][SERVICE_CHECK_CONFIG])
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_reload_config(call):
|
||||
@@ -197,6 +209,7 @@ def async_setup(hass, config):
|
||||
hass, conf.get(ha.DOMAIN) or {})
|
||||
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config)
|
||||
ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config,
|
||||
descriptions[ha.DOMAIN][SERVICE_RELOAD_CORE_CONFIG])
|
||||
|
||||
return True
|
||||
|
||||
@@ -10,24 +10,23 @@ from functools import partial
|
||||
from os import path
|
||||
|
||||
import voluptuous as vol
|
||||
from requests.exceptions import HTTPError, ConnectTimeout
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME,
|
||||
ATTR_ENTITY_ID, CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_EXCLUDE, CONF_NAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_HOMEASSISTANT_START)
|
||||
|
||||
REQUIREMENTS = ['abodepy==0.11.9']
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, ATTR_ENTITY_ID, CONF_USERNAME,
|
||||
CONF_PASSWORD, CONF_EXCLUDE, CONF_NAME, CONF_LIGHTS,
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from requests.exceptions import HTTPError, ConnectTimeout
|
||||
|
||||
REQUIREMENTS = ['abodepy==0.12.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_ATTRIBUTION = "Data provided by goabode.com"
|
||||
CONF_LIGHTS = "lights"
|
||||
CONF_POLLING = "polling"
|
||||
CONF_POLLING = 'polling'
|
||||
|
||||
DOMAIN = 'abode'
|
||||
|
||||
@@ -93,10 +92,9 @@ class AbodeSystem(object):
|
||||
def __init__(self, username, password, name, polling, exclude, lights):
|
||||
"""Initialize the system."""
|
||||
import abodepy
|
||||
self.abode = abodepy.Abode(username, password,
|
||||
auto_login=True,
|
||||
get_devices=True,
|
||||
get_automations=True)
|
||||
self.abode = abodepy.Abode(
|
||||
username, password, auto_login=True, get_devices=True,
|
||||
get_automations=True)
|
||||
self.name = name
|
||||
self.polling = polling
|
||||
self.exclude = exclude
|
||||
@@ -210,7 +208,7 @@ def setup_hass_services(hass):
|
||||
|
||||
|
||||
def setup_hass_events(hass):
|
||||
"""Home assistant start and stop callbacks."""
|
||||
"""Home Assistant start and stop callbacks."""
|
||||
def startup(event):
|
||||
"""Listen for push events."""
|
||||
hass.data[DOMAIN].abode.events.start()
|
||||
|
||||
@@ -124,20 +124,13 @@ def async_setup(hass, config):
|
||||
|
||||
method = "async_{}".format(SERVICE_TO_METHOD[service.service])
|
||||
|
||||
update_tasks = []
|
||||
for alarm in target_alarms:
|
||||
yield from getattr(alarm, method)(code)
|
||||
|
||||
update_tasks = []
|
||||
for alarm in target_alarms:
|
||||
if not alarm.should_poll:
|
||||
continue
|
||||
|
||||
update_coro = hass.async_add_job(
|
||||
alarm.async_update_ha_state(True))
|
||||
if hasattr(alarm, 'async_update'):
|
||||
update_tasks.append(update_coro)
|
||||
else:
|
||||
yield from update_coro
|
||||
update_tasks.append(alarm.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
121
homeassistant/components/alarm_control_panel/arlo.py
Normal file
121
homeassistant/components/alarm_control_panel/arlo.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Support for Arlo Alarm Control Panels.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.arlo/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanel, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.arlo import (DATA_ARLO, CONF_ATTRIBUTION)
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_DISARMED)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ARMED = 'armed'
|
||||
|
||||
CONF_HOME_MODE_NAME = 'home_mode_name'
|
||||
|
||||
DEPENDENCIES = ['arlo']
|
||||
|
||||
DISARMED = 'disarmed'
|
||||
|
||||
ICON = 'mdi:security'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string,
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Arlo Alarm Control Panels."""
|
||||
data = hass.data[DATA_ARLO]
|
||||
|
||||
if not data.base_stations:
|
||||
return
|
||||
|
||||
home_mode_name = config.get(CONF_HOME_MODE_NAME)
|
||||
base_stations = []
|
||||
for base_station in data.base_stations:
|
||||
base_stations.append(ArloBaseStation(base_station, home_mode_name))
|
||||
async_add_devices(base_stations, True)
|
||||
|
||||
|
||||
class ArloBaseStation(AlarmControlPanel):
|
||||
"""Representation of an Arlo Alarm Control Panel."""
|
||||
|
||||
def __init__(self, data, home_mode_name):
|
||||
"""Initialize the alarm control panel."""
|
||||
self._base_station = data
|
||||
self._home_mode_name = home_mode_name
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return icon."""
|
||||
return ICON
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._state
|
||||
|
||||
def update(self):
|
||||
"""Update the state of the device."""
|
||||
# PyArlo sometimes returns None for mode. So retry 3 times before
|
||||
# returning None.
|
||||
num_retries = 3
|
||||
i = 0
|
||||
while i < num_retries:
|
||||
mode = self._base_station.mode
|
||||
if mode:
|
||||
self._state = self._get_state_from_mode(mode)
|
||||
return
|
||||
i += 1
|
||||
self._state = None
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
self._base_station.mode = DISARMED
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
self._base_station.mode = ARMED
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_home(self, code=None):
|
||||
"""Send arm home command. Uses custom mode."""
|
||||
self._base_station.mode = self._home_mode_name
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the base station."""
|
||||
return self._base_station.name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
|
||||
'device_id': self._base_station.device_id
|
||||
}
|
||||
|
||||
def _get_state_from_mode(self, mode):
|
||||
"""Convert Arlo mode to Home Assistant state."""
|
||||
if mode == ARMED:
|
||||
return STATE_ALARM_ARMED_AWAY
|
||||
elif mode == DISARMED:
|
||||
return STATE_ALARM_DISARMED
|
||||
elif mode == self._home_mode_name:
|
||||
return STATE_ALARM_ARMED_HOME
|
||||
return None
|
||||
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED)
|
||||
|
||||
REQUIREMENTS = ['pythonegardia==1.0.21']
|
||||
REQUIREMENTS = ['pythonegardia==1.0.22']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,65 +1,61 @@
|
||||
alarm_disarm:
|
||||
description: Send the alarm the command for disarm
|
||||
# Describes the format for available alarm control panel services
|
||||
|
||||
alarm_disarm:
|
||||
description: Send the alarm the command for disarm.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of alarm control panel to disarm
|
||||
description: Name of alarm control panel to disarm.
|
||||
example: 'alarm_control_panel.downstairs'
|
||||
code:
|
||||
description: An optional code to disarm the alarm control panel with
|
||||
description: An optional code to disarm the alarm control panel with.
|
||||
example: 1234
|
||||
|
||||
alarm_arm_home:
|
||||
description: Send the alarm the command for arm home
|
||||
|
||||
description: Send the alarm the command for arm home.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of alarm control panel to arm home
|
||||
description: Name of alarm control panel to arm home.
|
||||
example: 'alarm_control_panel.downstairs'
|
||||
code:
|
||||
description: An optional code to arm home the alarm control panel with
|
||||
description: An optional code to arm home the alarm control panel with.
|
||||
example: 1234
|
||||
|
||||
alarm_arm_away:
|
||||
description: Send the alarm the command for arm away
|
||||
|
||||
description: Send the alarm the command for arm away.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of alarm control panel to arm away
|
||||
description: Name of alarm control panel to arm away.
|
||||
example: 'alarm_control_panel.downstairs'
|
||||
code:
|
||||
description: An optional code to arm away the alarm control panel with
|
||||
description: An optional code to arm away the alarm control panel with.
|
||||
example: 1234
|
||||
|
||||
alarm_arm_night:
|
||||
description: Send the alarm the command for arm night
|
||||
|
||||
description: Send the alarm the command for arm night.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of alarm control panel to arm night
|
||||
description: Name of alarm control panel to arm night.
|
||||
example: 'alarm_control_panel.downstairs'
|
||||
code:
|
||||
description: An optional code to arm night the alarm control panel with
|
||||
description: An optional code to arm night the alarm control panel with.
|
||||
example: 1234
|
||||
|
||||
alarm_trigger:
|
||||
description: Send the alarm the command for trigger
|
||||
|
||||
description: Send the alarm the command for trigger.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of alarm control panel to trigger
|
||||
description: Name of alarm control panel to trigger.
|
||||
example: 'alarm_control_panel.downstairs'
|
||||
code:
|
||||
description: An optional code to trigger the alarm control panel with
|
||||
description: An optional code to trigger the alarm control panel with.
|
||||
example: 1234
|
||||
|
||||
envisalink_alarm_keypress:
|
||||
description: Send custom keypresses to the alarm
|
||||
|
||||
description: Send custom keypresses to the alarm.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the alarm control panel to trigger
|
||||
description: Name of the alarm control panel to trigger.
|
||||
example: 'alarm_control_panel.downstairs'
|
||||
keypress:
|
||||
description: 'String to send to the alarm panel (1-6 characters)'
|
||||
description: 'String to send to the alarm panel (1-6 characters).'
|
||||
example: '*71'
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME)
|
||||
|
||||
REQUIREMENTS = ['total_connect_client==0.11']
|
||||
REQUIREMENTS = ['total_connect_client==0.12']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
"""Support for alexa Smart Home Skill API."""
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from uuid import uuid4
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF)
|
||||
from homeassistant.components import switch, light
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
HANDLERS = Registry()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_HEADER = 'header'
|
||||
ATTR_NAME = 'name'
|
||||
ATTR_NAMESPACE = 'namespace'
|
||||
ATTR_MESSAGE_ID = 'messageId'
|
||||
ATTR_PAYLOAD = 'payload'
|
||||
ATTR_PAYLOAD_VERSION = 'payloadVersion'
|
||||
API_DIRECTIVE = 'directive'
|
||||
API_EVENT = 'event'
|
||||
API_HEADER = 'header'
|
||||
API_PAYLOAD = 'payload'
|
||||
API_ENDPOINT = 'endpoint'
|
||||
|
||||
|
||||
MAPPING_COMPONENT = {
|
||||
switch.DOMAIN: ['SWITCH', ('turnOff', 'turnOn'), None],
|
||||
switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None],
|
||||
light.DOMAIN: [
|
||||
'LIGHT', ('turnOff', 'turnOn'), {
|
||||
light.SUPPORT_BRIGHTNESS: 'setPercentage'
|
||||
'LIGHT', ('Alexa.PowerController',), {
|
||||
light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController',
|
||||
light.SUPPORT_RGB_COLOR: 'Alexa.ColorController',
|
||||
light.SUPPORT_XY_COLOR: 'Alexa.ColorController',
|
||||
light.SUPPORT_COLOR_TEMP: 'Alexa.ColorTemperatureController',
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -32,51 +36,75 @@ MAPPING_COMPONENT = {
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, message):
|
||||
"""Handle incoming API messages."""
|
||||
assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2
|
||||
assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3'
|
||||
|
||||
# Read head data
|
||||
message = message[API_DIRECTIVE]
|
||||
namespace = message[API_HEADER]['namespace']
|
||||
name = message[API_HEADER]['name']
|
||||
|
||||
# Do we support this API request?
|
||||
funct_ref = HANDLERS.get(message[ATTR_HEADER][ATTR_NAME])
|
||||
funct_ref = HANDLERS.get((namespace, name))
|
||||
if not funct_ref:
|
||||
_LOGGER.warning(
|
||||
"Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME])
|
||||
"Unsupported API request %s/%s", namespace, name)
|
||||
return api_error(message)
|
||||
|
||||
return (yield from funct_ref(hass, message))
|
||||
|
||||
|
||||
def api_message(name, namespace, payload=None):
|
||||
def api_message(request, name='Response', namespace='Alexa', payload=None):
|
||||
"""Create a API formatted response message.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
payload = payload or {}
|
||||
return {
|
||||
ATTR_HEADER: {
|
||||
ATTR_MESSAGE_ID: str(uuid4()),
|
||||
ATTR_NAME: name,
|
||||
ATTR_NAMESPACE: namespace,
|
||||
ATTR_PAYLOAD_VERSION: '2',
|
||||
},
|
||||
ATTR_PAYLOAD: payload,
|
||||
|
||||
response = {
|
||||
API_EVENT: {
|
||||
API_HEADER: {
|
||||
'namespace': namespace,
|
||||
'name': name,
|
||||
'messageId': str(uuid4()),
|
||||
'payloadVersion': '3',
|
||||
},
|
||||
API_PAYLOAD: payload,
|
||||
}
|
||||
}
|
||||
|
||||
# If a correlation token exsits, add it to header / Need by Async requests
|
||||
token = request[API_HEADER].get('correlationToken')
|
||||
if token:
|
||||
response[API_EVENT][API_HEADER]['correlationToken'] = token
|
||||
|
||||
def api_error(request, exc='DriverInternalError'):
|
||||
# Extend event with endpoint object / Need by Async requests
|
||||
if API_ENDPOINT in request:
|
||||
response[API_EVENT][API_ENDPOINT] = request[API_ENDPOINT].copy()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def api_error(request, error_type='INTERNAL_ERROR', error_message=""):
|
||||
"""Create a API formatted error response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE])
|
||||
payload = {
|
||||
'type': error_type,
|
||||
'message': error_message,
|
||||
}
|
||||
|
||||
return api_message(request, name='ErrorResponse', payload=payload)
|
||||
|
||||
|
||||
@HANDLERS.register('DiscoverAppliancesRequest')
|
||||
@HANDLERS.register(('Alexa.Discovery', 'Discover'))
|
||||
@asyncio.coroutine
|
||||
def async_api_discovery(hass, request):
|
||||
"""Create a API formatted discovery response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
discovered_appliances = []
|
||||
discovery_endpoints = []
|
||||
|
||||
for entity in hass.states.async_all():
|
||||
class_data = MAPPING_COMPONENT.get(entity.domain)
|
||||
@@ -84,35 +112,42 @@ def async_api_discovery(hass, request):
|
||||
if not class_data:
|
||||
continue
|
||||
|
||||
appliance = {
|
||||
'actions': [],
|
||||
'applianceTypes': [class_data[0]],
|
||||
endpoint = {
|
||||
'displayCategories': [class_data[0]],
|
||||
'additionalApplianceDetails': {},
|
||||
'applianceId': entity.entity_id.replace('.', '#'),
|
||||
'friendlyDescription': '',
|
||||
'endpointId': entity.entity_id.replace('.', '#'),
|
||||
'friendlyName': entity.name,
|
||||
'isReachable': True,
|
||||
'description': '',
|
||||
'manufacturerName': 'Unknown',
|
||||
'modelName': 'Unknown',
|
||||
'version': 'Unknown',
|
||||
}
|
||||
actions = set()
|
||||
|
||||
# static actions
|
||||
if class_data[1]:
|
||||
appliance['actions'].extend(list(class_data[1]))
|
||||
actions |= set(class_data[1])
|
||||
|
||||
# dynamic actions
|
||||
if class_data[2]:
|
||||
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
for feature, action_name in class_data[2].items():
|
||||
if feature & supported > 0:
|
||||
appliance['actions'].append(action_name)
|
||||
actions.add(action_name)
|
||||
|
||||
discovered_appliances.append(appliance)
|
||||
# Write action into capabilities
|
||||
capabilities = []
|
||||
for action in actions:
|
||||
capabilities.append({
|
||||
'type': 'AlexaInterface',
|
||||
'interface': action,
|
||||
'version': 3,
|
||||
})
|
||||
|
||||
endpoint['capabilities'] = capabilities
|
||||
discovery_endpoints.append(endpoint)
|
||||
|
||||
return api_message(
|
||||
'DiscoverAppliancesResponse', 'Alexa.ConnectedHome.Discovery',
|
||||
payload={'discoveredAppliances': discovered_appliances})
|
||||
request, name='Discover.Response', namespace='Alexa.Discovery',
|
||||
payload={'endpoints': discovery_endpoints})
|
||||
|
||||
|
||||
def extract_entity(funct):
|
||||
@@ -120,22 +155,21 @@ def extract_entity(funct):
|
||||
@asyncio.coroutine
|
||||
def async_api_entity_wrapper(hass, request):
|
||||
"""Process a turn on request."""
|
||||
entity_id = \
|
||||
request[ATTR_PAYLOAD]['appliance']['applianceId'].replace('#', '.')
|
||||
entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.')
|
||||
|
||||
# extract state object
|
||||
entity = hass.states.get(entity_id)
|
||||
if not entity:
|
||||
_LOGGER.error("Can't process %s for %s",
|
||||
request[ATTR_HEADER][ATTR_NAME], entity_id)
|
||||
return api_error(request)
|
||||
request[API_HEADER]['name'], entity_id)
|
||||
return api_error(request, error_type='NO_SUCH_ENDPOINT')
|
||||
|
||||
return (yield from funct(hass, request, entity))
|
||||
|
||||
return async_api_entity_wrapper
|
||||
|
||||
|
||||
@HANDLERS.register('TurnOnRequest')
|
||||
@HANDLERS.register(('Alexa.PowerController', 'TurnOn'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_turn_on(hass, request, entity):
|
||||
@@ -144,10 +178,10 @@ def async_api_turn_on(hass, request, entity):
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=True)
|
||||
|
||||
return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control')
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register('TurnOffRequest')
|
||||
@HANDLERS.register(('Alexa.PowerController', 'TurnOff'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_turn_off(hass, request, entity):
|
||||
@@ -156,22 +190,122 @@ def async_api_turn_off(hass, request, entity):
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=True)
|
||||
|
||||
return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control')
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register('SetPercentageRequest')
|
||||
@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_percentage(hass, request, entity):
|
||||
"""Process a set percentage request."""
|
||||
if entity.domain == light.DOMAIN:
|
||||
brightness = request[ATTR_PAYLOAD]['percentageState']['value']
|
||||
def async_api_set_brightness(hass, request, entity):
|
||||
"""Process a set brightness request."""
|
||||
brightness = int(request[API_PAYLOAD]['brightness'])
|
||||
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: brightness,
|
||||
}, blocking=True)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_adjust_brightness(hass, request, entity):
|
||||
"""Process a adjust brightness request."""
|
||||
brightness_delta = int(request[API_PAYLOAD]['brightnessDelta'])
|
||||
|
||||
# read current state
|
||||
try:
|
||||
current = math.floor(
|
||||
int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100)
|
||||
except ZeroDivisionError:
|
||||
current = 0
|
||||
|
||||
# set brightness
|
||||
brightness = max(0, brightness_delta + current)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: brightness,
|
||||
}, blocking=True)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ColorController', 'SetColor'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_color(hass, request, entity):
|
||||
"""Process a set color request."""
|
||||
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
rgb = color_util.color_hsb_to_RGB(
|
||||
float(request[API_PAYLOAD]['color']['hue']),
|
||||
float(request[API_PAYLOAD]['color']['saturation']),
|
||||
float(request[API_PAYLOAD]['color']['brightness'])
|
||||
)
|
||||
|
||||
if supported & light.SUPPORT_RGB_COLOR > 0:
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS: brightness,
|
||||
light.ATTR_RGB_COLOR: rgb,
|
||||
}, blocking=True)
|
||||
else:
|
||||
return api_error(request)
|
||||
xyz = color_util.color_RGB_to_xy(*rgb)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_XY_COLOR: (xyz[0], xyz[1]),
|
||||
light.ATTR_BRIGHTNESS: xyz[2],
|
||||
}, blocking=True)
|
||||
|
||||
return api_message(
|
||||
'SetPercentageConfirmation', 'Alexa.ConnectedHome.Control')
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_color_temperature(hass, request, entity):
|
||||
"""Process a set color temperature request."""
|
||||
kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin'])
|
||||
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_KELVIN: kelvin,
|
||||
}, blocking=True)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(
|
||||
('Alexa.ColorTemperatureController', 'DecreaseColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_decrease_color_temp(hass, request, entity):
|
||||
"""Process a decrease color temperature request."""
|
||||
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
||||
max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS))
|
||||
|
||||
value = min(max_mireds, current + 50)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_COLOR_TEMP: value,
|
||||
}, blocking=True)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(
|
||||
('Alexa.ColorTemperatureController', 'IncreaseColorTemperature'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_increase_color_temp(hass, request, entity):
|
||||
"""Process a increase color temperature request."""
|
||||
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
||||
min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
|
||||
|
||||
value = max(min_mireds, current - 50)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_COLOR_TEMP: value,
|
||||
}, blocking=True)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
@@ -262,7 +262,11 @@ class APIEventView(HomeAssistantView):
|
||||
def post(self, request, event_type):
|
||||
"""Fire events."""
|
||||
body = yield from request.text()
|
||||
event_data = json.loads(body) if body else None
|
||||
try:
|
||||
event_data = json.loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message('Event data should be valid JSON',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
if event_data is not None and not isinstance(event_data, dict):
|
||||
return self.json_message('Event data should be a JSON object',
|
||||
@@ -309,7 +313,11 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
"""
|
||||
hass = request.app['hass']
|
||||
body = yield from request.text()
|
||||
data = json.loads(body) if body else None
|
||||
try:
|
||||
data = json.loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message('Data should be valid JSON',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
with AsyncTrackStates(hass) as changed_states:
|
||||
yield from hass.services.async_call(domain, service, data, True)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
This component provides basic support for Netgear Arlo IP cameras.
|
||||
This component provides support for Netgear Arlo IP cameras.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/arlo/
|
||||
@@ -12,7 +12,7 @@ from requests.exceptions import HTTPError, ConnectTimeout
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
|
||||
|
||||
REQUIREMENTS = ['pyarlo==0.0.6']
|
||||
REQUIREMENTS = ['pyarlo==0.0.7']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +23,7 @@ DEFAULT_BRAND = 'Netgear Arlo'
|
||||
DOMAIN = 'arlo'
|
||||
|
||||
NOTIFICATION_ID = 'arlo_notification'
|
||||
NOTIFICATION_TITLE = 'Arlo Camera Setup'
|
||||
NOTIFICATION_TITLE = 'Arlo Component Setup'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
|
||||
@@ -29,18 +29,27 @@ TRIGGER_SCHEMA = vol.Schema({
|
||||
def async_trigger(hass, config, action):
|
||||
"""Listen for events based on configuration."""
|
||||
event_type = config.get(CONF_EVENT_TYPE)
|
||||
event_data = config.get(CONF_EVENT_DATA)
|
||||
event_data_schema = vol.Schema(
|
||||
config.get(CONF_EVENT_DATA),
|
||||
extra=vol.ALLOW_EXTRA) if config.get(CONF_EVENT_DATA) else None
|
||||
|
||||
@callback
|
||||
def handle_event(event):
|
||||
"""Listen for events and calls the action when data matches."""
|
||||
if not event_data or all(val == event.data.get(key) for key, val
|
||||
in event_data.items()):
|
||||
hass.async_run_job(action, {
|
||||
'trigger': {
|
||||
'platform': 'event',
|
||||
'event': event,
|
||||
},
|
||||
})
|
||||
if event_data_schema:
|
||||
# Check that the event data matches the configured
|
||||
# schema if one was provided
|
||||
try:
|
||||
event_data_schema(event.data)
|
||||
except vol.Invalid:
|
||||
# If event data doesn't match requested schema, skip event
|
||||
return
|
||||
|
||||
hass.async_run_job(action, {
|
||||
'trigger': {
|
||||
'platform': 'event',
|
||||
'event': event,
|
||||
},
|
||||
})
|
||||
|
||||
return hass.bus.async_listen(event_type, handle_event)
|
||||
|
||||
@@ -38,13 +38,14 @@ def async_trigger(hass, config, action):
|
||||
time_delta = config.get(CONF_FOR)
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
async_remove_track_same = None
|
||||
already_triggered = False
|
||||
|
||||
if value_template is not None:
|
||||
value_template.hass = hass
|
||||
|
||||
@callback
|
||||
def check_numeric_state(entity, from_s, to_s):
|
||||
"""Return True if they should trigger."""
|
||||
"""Return True if criteria are now met."""
|
||||
if to_s is None:
|
||||
return False
|
||||
|
||||
@@ -56,51 +57,39 @@ def async_trigger(hass, config, action):
|
||||
'above': above,
|
||||
}
|
||||
}
|
||||
|
||||
# If new one doesn't match, nothing to do
|
||||
if not condition.async_numeric_state(
|
||||
hass, to_s, below, above, value_template, variables):
|
||||
return False
|
||||
|
||||
return True
|
||||
return condition.async_numeric_state(
|
||||
hass, to_s, below, above, value_template, variables)
|
||||
|
||||
@callback
|
||||
def state_automation_listener(entity, from_s, to_s):
|
||||
"""Listen for state changes and calls action."""
|
||||
nonlocal async_remove_track_same
|
||||
|
||||
if not check_numeric_state(entity, from_s, to_s):
|
||||
return
|
||||
|
||||
variables = {
|
||||
'trigger': {
|
||||
'platform': 'numeric_state',
|
||||
'entity_id': entity,
|
||||
'below': below,
|
||||
'above': above,
|
||||
'from_state': from_s,
|
||||
'to_state': to_s,
|
||||
}
|
||||
}
|
||||
|
||||
# Only match if old didn't exist or existed but didn't match
|
||||
# Written as: skip if old one did exist and matched
|
||||
if from_s is not None and condition.async_numeric_state(
|
||||
hass, from_s, below, above, value_template, variables):
|
||||
return
|
||||
nonlocal already_triggered, async_remove_track_same
|
||||
|
||||
@callback
|
||||
def call_action():
|
||||
"""Call action with right context."""
|
||||
hass.async_run_job(action, variables)
|
||||
hass.async_run_job(action, {
|
||||
'trigger': {
|
||||
'platform': 'numeric_state',
|
||||
'entity_id': entity,
|
||||
'below': below,
|
||||
'above': above,
|
||||
'from_state': from_s,
|
||||
'to_state': to_s,
|
||||
}
|
||||
})
|
||||
|
||||
if not time_delta:
|
||||
call_action()
|
||||
return
|
||||
matching = check_numeric_state(entity, from_s, to_s)
|
||||
|
||||
async_remove_track_same = async_track_same_state(
|
||||
hass, True, time_delta, call_action, entity_ids=entity_id,
|
||||
async_check_func=check_numeric_state)
|
||||
if matching and not already_triggered:
|
||||
if time_delta:
|
||||
async_remove_track_same = async_track_same_state(
|
||||
hass, time_delta, call_action, entity_ids=entity_id,
|
||||
async_check_same_func=check_numeric_state)
|
||||
else:
|
||||
call_action()
|
||||
|
||||
already_triggered = matching
|
||||
|
||||
unsub = async_track_state_change(
|
||||
hass, entity_id, state_automation_listener)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Describes the format for available automation services
|
||||
|
||||
turn_on:
|
||||
description: Enable an automation.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the automation to turn on.
|
||||
@@ -8,7 +9,6 @@ turn_on:
|
||||
|
||||
turn_off:
|
||||
description: Disable an automation.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the automation to turn off.
|
||||
@@ -16,7 +16,6 @@ turn_off:
|
||||
|
||||
toggle:
|
||||
description: Toggle an automation.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the automation to toggle on/off.
|
||||
@@ -24,7 +23,6 @@ toggle:
|
||||
|
||||
trigger:
|
||||
description: Trigger the action of an automation.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the automation to trigger.
|
||||
|
||||
@@ -65,7 +65,9 @@ def async_trigger(hass, config, action):
|
||||
return
|
||||
|
||||
async_remove_track_same = async_track_same_state(
|
||||
hass, to_s.state, time_delta, call_action, entity_ids=entity_id)
|
||||
hass, time_delta, call_action,
|
||||
lambda _, _2, to_state: to_state.state == to_s.state,
|
||||
entity_ids=entity_id)
|
||||
|
||||
unsub = async_track_state_change(
|
||||
hass, entity_id, state_automation_listener, from_state, to_state)
|
||||
|
||||
@@ -11,19 +11,20 @@ import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.discovery import SERVICE_AXIS
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED,
|
||||
CONF_HOST, CONF_INCLUDE, CONF_NAME,
|
||||
CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME,
|
||||
CONF_USERNAME, EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.components.discovery import SERVICE_AXIS
|
||||
CONF_EVENT, CONF_HOST, CONF_INCLUDE,
|
||||
CONF_NAME, CONF_PASSWORD, CONF_PORT,
|
||||
CONF_TRIGGER_TIME, CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
|
||||
REQUIREMENTS = ['axis==12']
|
||||
REQUIREMENTS = ['axis==14']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,10 +88,13 @@ def request_configuration(hass, config, name, host, serialnumber):
|
||||
configurator.notify_errors(request_id,
|
||||
"Functionality mandatory.")
|
||||
return False
|
||||
|
||||
callback_data[CONF_INCLUDE] = callback_data[CONF_INCLUDE].split()
|
||||
callback_data[CONF_HOST] = host
|
||||
|
||||
if CONF_NAME not in callback_data:
|
||||
callback_data[CONF_NAME] = name
|
||||
|
||||
try:
|
||||
device_config = DEVICE_SCHEMA(callback_data)
|
||||
except vol.Invalid:
|
||||
@@ -101,7 +105,6 @@ def request_configuration(hass, config, name, host, serialnumber):
|
||||
if setup_device(hass, config, device_config):
|
||||
config_file = _read_config(hass)
|
||||
config_file[serialnumber] = dict(device_config)
|
||||
del config_file[serialnumber]['hass']
|
||||
_write_config(hass, config_file)
|
||||
configurator.request_done(request_id)
|
||||
else:
|
||||
@@ -146,10 +149,10 @@ def request_configuration(hass, config, name, host, serialnumber):
|
||||
def setup(hass, config):
|
||||
"""Common setup for Axis devices."""
|
||||
def _shutdown(call): # pylint: disable=unused-argument
|
||||
"""Stop the metadatastream on shutdown."""
|
||||
"""Stop the event stream on shutdown."""
|
||||
for serialnumber, device in AXIS_DEVICES.items():
|
||||
_LOGGER.info("Stopping metadatastream for %s.", serialnumber)
|
||||
device.stop_metadatastream()
|
||||
_LOGGER.info("Stopping event stream for %s.", serialnumber)
|
||||
device.stop()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
||||
|
||||
@@ -162,7 +165,7 @@ def setup(hass, config):
|
||||
if serialnumber not in AXIS_DEVICES:
|
||||
config_file = _read_config(hass)
|
||||
if serialnumber in config_file:
|
||||
# Device config saved to file
|
||||
# Device config previously saved to file
|
||||
try:
|
||||
device_config = DEVICE_SCHEMA(config_file[serialnumber])
|
||||
device_config[CONF_HOST] = host
|
||||
@@ -178,10 +181,8 @@ def setup(hass, config):
|
||||
else:
|
||||
# Device already registered, but on a different IP
|
||||
device = AXIS_DEVICES[serialnumber]
|
||||
device.url = host
|
||||
async_dispatcher_send(hass,
|
||||
DOMAIN + '_' + device.name + '_new_ip',
|
||||
host)
|
||||
device.config.host = host
|
||||
dispatcher_send(hass, DOMAIN + '_' + device.name + '_new_ip', host)
|
||||
|
||||
# Register discovery service
|
||||
discovery.listen(hass, SERVICE_AXIS, axis_device_discovered)
|
||||
@@ -202,10 +203,11 @@ def setup(hass, config):
|
||||
"""Service to send a message."""
|
||||
for _, device in AXIS_DEVICES.items():
|
||||
if device.name == call.data[CONF_NAME]:
|
||||
response = device.do_request(call.data[SERVICE_CGI],
|
||||
call.data[SERVICE_ACTION],
|
||||
call.data[SERVICE_PARAM])
|
||||
hass.bus.async_fire(SERVICE_VAPIX_CALL_RESPONSE, response)
|
||||
response = device.vapix.do_request(
|
||||
call.data[SERVICE_CGI],
|
||||
call.data[SERVICE_ACTION],
|
||||
call.data[SERVICE_PARAM])
|
||||
hass.bus.fire(SERVICE_VAPIX_CALL_RESPONSE, response)
|
||||
return True
|
||||
_LOGGER.info("Couldn\'t find device %s", call.data[CONF_NAME])
|
||||
return False
|
||||
@@ -216,7 +218,6 @@ def setup(hass, config):
|
||||
vapix_service,
|
||||
descriptions[DOMAIN][SERVICE_VAPIX_CALL],
|
||||
schema=SERVICE_SCHEMA)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -224,9 +225,28 @@ def setup_device(hass, config, device_config):
|
||||
"""Set up device."""
|
||||
from axis import AxisDevice
|
||||
|
||||
device_config['hass'] = hass
|
||||
device = AxisDevice(device_config) # Initialize device
|
||||
enable_metadatastream = False
|
||||
def signal_callback(action, event):
|
||||
"""Callback to configure events when initialized on event stream."""
|
||||
if action == 'add':
|
||||
event_config = {
|
||||
CONF_EVENT: event,
|
||||
CONF_NAME: device_config[CONF_NAME],
|
||||
ATTR_LOCATION: device_config[ATTR_LOCATION],
|
||||
CONF_TRIGGER_TIME: device_config[CONF_TRIGGER_TIME]
|
||||
}
|
||||
component = event.event_platform
|
||||
discovery.load_platform(hass,
|
||||
component,
|
||||
DOMAIN,
|
||||
event_config,
|
||||
config)
|
||||
|
||||
event_types = list(filter(lambda x: x in device_config[CONF_INCLUDE],
|
||||
EVENT_TYPES))
|
||||
device_config['events'] = event_types
|
||||
device_config['signal'] = signal_callback
|
||||
device = AxisDevice(hass.loop, **device_config)
|
||||
device.name = device_config[CONF_NAME]
|
||||
|
||||
if device.serial_number is None:
|
||||
# If there is no serial number a connection could not be made
|
||||
@@ -234,16 +254,10 @@ def setup_device(hass, config, device_config):
|
||||
return False
|
||||
|
||||
for component in device_config[CONF_INCLUDE]:
|
||||
if component in EVENT_TYPES:
|
||||
# Sensors are created by device calling event_initialized
|
||||
# when receiving initialize messages on metadatastream
|
||||
device.add_event_topic(convert(component, 'type', 'subscribe'))
|
||||
if not enable_metadatastream:
|
||||
enable_metadatastream = True
|
||||
else:
|
||||
if component == 'camera':
|
||||
camera_config = {
|
||||
CONF_HOST: device_config[CONF_HOST],
|
||||
CONF_NAME: device_config[CONF_NAME],
|
||||
CONF_HOST: device_config[CONF_HOST],
|
||||
CONF_PORT: device_config[CONF_PORT],
|
||||
CONF_USERNAME: device_config[CONF_USERNAME],
|
||||
CONF_PASSWORD: device_config[CONF_PASSWORD]
|
||||
@@ -254,17 +268,8 @@ def setup_device(hass, config, device_config):
|
||||
camera_config,
|
||||
config)
|
||||
|
||||
if enable_metadatastream:
|
||||
device.initialize_new_event = event_initialized
|
||||
if not device.initiate_metadatastream():
|
||||
hass.components.persistent_notification.create(
|
||||
'Dependency missing for sensors, '
|
||||
'please check documentation',
|
||||
title=DOMAIN,
|
||||
notification_id='axis_notification')
|
||||
|
||||
AXIS_DEVICES[device.serial_number] = device
|
||||
|
||||
hass.add_job(device.start)
|
||||
return True
|
||||
|
||||
|
||||
@@ -287,25 +292,16 @@ def _write_config(hass, config):
|
||||
outfile.write(data)
|
||||
|
||||
|
||||
def event_initialized(event):
|
||||
"""Register event initialized on metadatastream here."""
|
||||
hass = event.device_config('hass')
|
||||
discovery.load_platform(hass,
|
||||
convert(event.topic, 'topic', 'platform'),
|
||||
DOMAIN, {'axis_event': event})
|
||||
|
||||
|
||||
class AxisDeviceEvent(Entity):
|
||||
"""Representation of a Axis device event."""
|
||||
|
||||
def __init__(self, axis_event):
|
||||
def __init__(self, event_config):
|
||||
"""Initialize the event."""
|
||||
self.axis_event = axis_event
|
||||
self._event_class = convert(self.axis_event.topic, 'topic', 'class')
|
||||
self._name = '{}_{}_{}'.format(self.axis_event.device_name,
|
||||
convert(self.axis_event.topic,
|
||||
'topic', 'type'),
|
||||
self.axis_event = event_config[CONF_EVENT]
|
||||
self._name = '{}_{}_{}'.format(event_config[CONF_NAME],
|
||||
self.axis_event.event_type,
|
||||
self.axis_event.id)
|
||||
self.location = event_config[ATTR_LOCATION]
|
||||
self.axis_event.callback = self._update_callback
|
||||
|
||||
def _update_callback(self):
|
||||
@@ -321,7 +317,7 @@ class AxisDeviceEvent(Entity):
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of the event."""
|
||||
return self._event_class
|
||||
return self.axis_event.event_class
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -336,52 +332,6 @@ class AxisDeviceEvent(Entity):
|
||||
tripped = self.axis_event.is_tripped
|
||||
attr[ATTR_TRIPPED] = 'True' if tripped else 'False'
|
||||
|
||||
location = self.axis_event.device_config(ATTR_LOCATION)
|
||||
if location:
|
||||
attr[ATTR_LOCATION] = location
|
||||
attr[ATTR_LOCATION] = self.location
|
||||
|
||||
return attr
|
||||
|
||||
|
||||
def convert(item, from_key, to_key):
|
||||
"""Translate between Axis and HASS syntax."""
|
||||
for entry in REMAP:
|
||||
if entry[from_key] == item:
|
||||
return entry[to_key]
|
||||
|
||||
|
||||
REMAP = [{'type': 'motion',
|
||||
'class': 'motion',
|
||||
'topic': 'tns1:VideoAnalytics/tnsaxis:MotionDetection',
|
||||
'subscribe': 'onvif:VideoAnalytics/axis:MotionDetection',
|
||||
'platform': 'binary_sensor'},
|
||||
{'type': 'vmd3',
|
||||
'class': 'motion',
|
||||
'topic': 'tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1',
|
||||
'subscribe': 'onvif:RuleEngine/axis:VMD3/vmd3_video_1',
|
||||
'platform': 'binary_sensor'},
|
||||
{'type': 'pir',
|
||||
'class': 'motion',
|
||||
'topic': 'tns1:Device/tnsaxis:Sensor/PIR',
|
||||
'subscribe': 'onvif:Device/axis:Sensor/axis:PIR',
|
||||
'platform': 'binary_sensor'},
|
||||
{'type': 'sound',
|
||||
'class': 'sound',
|
||||
'topic': 'tns1:AudioSource/tnsaxis:TriggerLevel',
|
||||
'subscribe': 'onvif:AudioSource/axis:TriggerLevel',
|
||||
'platform': 'binary_sensor'},
|
||||
{'type': 'daynight',
|
||||
'class': 'light',
|
||||
'topic': 'tns1:VideoSource/tnsaxis:DayNightVision',
|
||||
'subscribe': 'onvif:VideoSource/axis:DayNightVision',
|
||||
'platform': 'binary_sensor'},
|
||||
{'type': 'tampering',
|
||||
'class': 'safety',
|
||||
'topic': 'tns1:VideoSource/tnsaxis:Tampering',
|
||||
'subscribe': 'onvif:VideoSource/axis:Tampering',
|
||||
'platform': 'binary_sensor'},
|
||||
{'type': 'input',
|
||||
'class': 'input',
|
||||
'topic': 'tns1:Device/tnsaxis:IO/Port',
|
||||
'subscribe': 'onvif:Device/axis:IO/Port',
|
||||
'platform': 'binary_sensor'}, ]
|
||||
|
||||
@@ -21,19 +21,19 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup Axis device event."""
|
||||
add_devices([AxisBinarySensor(discovery_info['axis_event'], hass)], True)
|
||||
add_devices([AxisBinarySensor(hass, discovery_info)], True)
|
||||
|
||||
|
||||
class AxisBinarySensor(AxisDeviceEvent, BinarySensorDevice):
|
||||
"""Representation of a binary Axis event."""
|
||||
|
||||
def __init__(self, axis_event, hass):
|
||||
def __init__(self, hass, event_config):
|
||||
"""Initialize the binary sensor."""
|
||||
self.hass = hass
|
||||
self._state = False
|
||||
self._delay = axis_event.device_config(CONF_TRIGGER_TIME)
|
||||
self._delay = event_config[CONF_TRIGGER_TIME]
|
||||
self._timer = None
|
||||
AxisDeviceEvent.__init__(self, axis_event)
|
||||
AxisDeviceEvent.__init__(self, event_config)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
|
||||
@@ -22,6 +22,10 @@ from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_OBSERVATIONS = 'observations'
|
||||
ATTR_PROBABILITY = 'probability'
|
||||
ATTR_PROBABILITY_THRESHOLD = 'probability_threshold'
|
||||
|
||||
CONF_OBSERVATIONS = 'observations'
|
||||
CONF_PRIOR = 'prior'
|
||||
CONF_PROBABILITY_THRESHOLD = 'probability_threshold'
|
||||
@@ -29,7 +33,8 @@ CONF_P_GIVEN_F = 'prob_given_false'
|
||||
CONF_P_GIVEN_T = 'prob_given_true'
|
||||
CONF_TO_STATE = 'to_state'
|
||||
|
||||
DEFAULT_NAME = 'BayesianBinary'
|
||||
DEFAULT_NAME = "Bayesian Binary Sensor"
|
||||
DEFAULT_PROBABILITY_THRESHOLD = 0.5
|
||||
|
||||
NUMERIC_STATE_SCHEMA = vol.Schema({
|
||||
CONF_PLATFORM: 'numeric_state',
|
||||
@@ -49,16 +54,14 @@ STATE_SCHEMA = vol.Schema({
|
||||
}, required=True)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME):
|
||||
cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS): cv.string,
|
||||
vol.Required(CONF_OBSERVATIONS): vol.Schema(
|
||||
vol.All(cv.ensure_list, [vol.Any(NUMERIC_STATE_SCHEMA,
|
||||
STATE_SCHEMA)])
|
||||
),
|
||||
vol.Required(CONF_OBSERVATIONS):
|
||||
vol.Schema(vol.All(cv.ensure_list,
|
||||
[vol.Any(NUMERIC_STATE_SCHEMA, STATE_SCHEMA)])),
|
||||
vol.Required(CONF_PRIOR): vol.Coerce(float),
|
||||
vol.Optional(CONF_PROBABILITY_THRESHOLD):
|
||||
vol.Coerce(float),
|
||||
vol.Optional(CONF_PROBABILITY_THRESHOLD,
|
||||
default=DEFAULT_PROBABILITY_THRESHOLD): vol.Coerce(float),
|
||||
})
|
||||
|
||||
|
||||
@@ -73,16 +76,16 @@ def update_probability(prior, prob_true, prob_false):
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Threshold sensor."""
|
||||
"""Set up the Bayesian Binary sensor."""
|
||||
name = config.get(CONF_NAME)
|
||||
observations = config.get(CONF_OBSERVATIONS)
|
||||
prior = config.get(CONF_PRIOR)
|
||||
probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD, 0.5)
|
||||
probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD)
|
||||
device_class = config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
async_add_devices([
|
||||
BayesianBinarySensor(name, prior, observations, probability_threshold,
|
||||
device_class)
|
||||
BayesianBinarySensor(
|
||||
name, prior, observations, probability_threshold, device_class)
|
||||
], True)
|
||||
|
||||
|
||||
@@ -107,7 +110,7 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
self.entity_obs = dict.fromkeys(to_observe, [])
|
||||
|
||||
for ind, obs in enumerate(self._observations):
|
||||
obs["id"] = ind
|
||||
obs['id'] = ind
|
||||
self.entity_obs[obs['entity_id']].append(obs)
|
||||
|
||||
self.watchers = {
|
||||
@@ -117,7 +120,7 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Call when entity about to be added to hass."""
|
||||
"""Call when entity about to be added."""
|
||||
@callback
|
||||
# pylint: disable=invalid-name
|
||||
def async_threshold_sensor_state_listener(entity, old_state,
|
||||
@@ -135,8 +138,8 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
|
||||
prior = self.prior
|
||||
for obs in self.current_obs.values():
|
||||
prior = update_probability(prior, obs['prob_true'],
|
||||
obs['prob_false'])
|
||||
prior = update_probability(
|
||||
prior, obs['prob_true'], obs['prob_false'])
|
||||
self.probability = prior
|
||||
|
||||
self.hass.async_add_job(self.async_update_ha_state, True)
|
||||
@@ -206,9 +209,9 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes of the sensor."""
|
||||
return {
|
||||
'observations': [val for val in self.current_obs.values()],
|
||||
'probability': round(self.probability, 2),
|
||||
'probability_threshold': self._probability_threshold
|
||||
ATTR_OBSERVATIONS: [val for val in self.current_obs.values()],
|
||||
ATTR_PROBABILITY: round(self.probability, 2),
|
||||
ATTR_PROBABILITY_THRESHOLD: self._probability_threshold,
|
||||
}
|
||||
|
||||
@asyncio.coroutine
|
||||
|
||||
69
homeassistant/components/binary_sensor/gc100.py
Normal file
69
homeassistant/components/binary_sensor/gc100.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Support for binary sensor using GC100.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.gc100/
|
||||
"""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.gc100 import DATA_GC100, CONF_PORTS
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import DEVICE_DEFAULT_NAME
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DEPENDENCIES = ['gc100']
|
||||
|
||||
_SENSORS_SCHEMA = vol.Schema({
|
||||
cv.string: cv.string,
|
||||
})
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SENSORS_SCHEMA])
|
||||
})
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the GC100 devices."""
|
||||
binary_sensors = []
|
||||
ports = config.get(CONF_PORTS)
|
||||
for port in ports:
|
||||
for port_addr, port_name in port.items():
|
||||
binary_sensors.append(GC100BinarySensor(
|
||||
port_name, port_addr, hass.data[DATA_GC100]))
|
||||
add_devices(binary_sensors, True)
|
||||
|
||||
|
||||
class GC100BinarySensor(BinarySensorDevice):
|
||||
"""Representation of a binary sensor from GC100."""
|
||||
|
||||
def __init__(self, name, port_addr, gc100):
|
||||
"""Initialize the GC100 binary sensor."""
|
||||
# pylint: disable=no-member
|
||||
self._name = name or DEVICE_DEFAULT_NAME
|
||||
self._port_addr = port_addr
|
||||
self._gc100 = gc100
|
||||
self._state = None
|
||||
|
||||
# Subscribe to be notified about state changes (PUSH)
|
||||
self._gc100.subscribe(self._port_addr, self.set_state)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the state of the entity."""
|
||||
return self._state
|
||||
|
||||
def update(self):
|
||||
"""Update the sensor state."""
|
||||
self._gc100.read_sensor(self._port_addr, self.set_state)
|
||||
|
||||
def set_state(self, state):
|
||||
"""Set the current state."""
|
||||
self._state = state == 1
|
||||
self.schedule_update_ha_state()
|
||||
@@ -13,7 +13,8 @@ import voluptuous as vol
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import (CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE, CONF_SHOW_ON_MAP)
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = ['pyiss==1.0.1']
|
||||
@@ -23,8 +24,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
ATTR_ISS_NEXT_RISE = 'next_rise'
|
||||
ATTR_ISS_NUMBER_PEOPLE_SPACE = 'number_of_people_in_space'
|
||||
|
||||
CONF_SHOW_ON_MAP = 'show_on_map'
|
||||
|
||||
DEFAULT_NAME = 'ISS'
|
||||
DEFAULT_DEVICE_CLASS = 'visible'
|
||||
|
||||
|
||||
96
homeassistant/components/binary_sensor/linode.py
Normal file
96
homeassistant/components/binary_sensor/linode.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Support for monitoring the state of Linode Nodes.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.linode/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.linode import (
|
||||
CONF_NODES, ATTR_CREATED, ATTR_NODE_ID, ATTR_NODE_NAME,
|
||||
ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY,
|
||||
ATTR_REGION, ATTR_VCPUS, DATA_LINODE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'Node'
|
||||
DEFAULT_DEVICE_CLASS = 'moving'
|
||||
DEPENDENCIES = ['linode']
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_NODES): vol.All(cv.ensure_list, [cv.string]),
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Linode droplet sensor."""
|
||||
linode = hass.data.get(DATA_LINODE)
|
||||
nodes = config.get(CONF_NODES)
|
||||
|
||||
dev = []
|
||||
for node in nodes:
|
||||
node_id = linode.get_node_id(node)
|
||||
if node_id is None:
|
||||
_LOGGER.error("Node %s is not available", node)
|
||||
return
|
||||
dev.append(LinodeBinarySensor(linode, node_id))
|
||||
|
||||
add_devices(dev, True)
|
||||
|
||||
|
||||
class LinodeBinarySensor(BinarySensorDevice):
|
||||
"""Representation of a Linode droplet sensor."""
|
||||
|
||||
def __init__(self, li, node_id):
|
||||
"""Initialize a new Linode sensor."""
|
||||
self._linode = li
|
||||
self._node_id = node_id
|
||||
self._state = None
|
||||
self.data = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
if self.data is not None:
|
||||
return self.data.label
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
if self.data is not None:
|
||||
return self.data.status == 'running'
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor."""
|
||||
return DEFAULT_DEVICE_CLASS
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes of the Linode Node."""
|
||||
if self.data:
|
||||
return {
|
||||
ATTR_CREATED: self.data.created,
|
||||
ATTR_NODE_ID: self.data.id,
|
||||
ATTR_NODE_NAME: self.data.label,
|
||||
ATTR_IPV4_ADDRESS: self.data.ipv4,
|
||||
ATTR_IPV6_ADDRESS: self.data.ipv6,
|
||||
ATTR_MEMORY: self.data.specs.memory,
|
||||
ATTR_REGION: self.data.region.country,
|
||||
ATTR_VCPUS: self.data.specs.vcpus,
|
||||
}
|
||||
return {}
|
||||
|
||||
def update(self):
|
||||
"""Update state of sensor."""
|
||||
self._linode.update()
|
||||
if self._linode.data is not None:
|
||||
for node in self._linode.data:
|
||||
if node.id == self._node_id:
|
||||
self.data = node
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.netatmo import CameraData
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.const import CONF_TIMEOUT, CONF_OFFSET
|
||||
from homeassistant.const import CONF_TIMEOUT
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -44,14 +44,12 @@ CONF_WELCOME_SENSORS = 'welcome_sensors'
|
||||
CONF_PRESENCE_SENSORS = 'presence_sensors'
|
||||
CONF_TAG_SENSORS = 'tag_sensors'
|
||||
|
||||
DEFAULT_TIMEOUT = 15
|
||||
DEFAULT_OFFSET = 90
|
||||
DEFAULT_TIMEOUT = 90
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_CAMERAS, default=[]):
|
||||
vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_HOME): cv.string,
|
||||
vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): cv.positive_int,
|
||||
vol.Optional(CONF_PRESENCE_SENSORS, default=PRESENCE_SENSOR_TYPES):
|
||||
vol.All(cv.ensure_list, [vol.In(PRESENCE_SENSOR_TYPES)]),
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
|
||||
@@ -66,7 +64,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
netatmo = get_component('netatmo')
|
||||
home = config.get(CONF_HOME)
|
||||
timeout = config.get(CONF_TIMEOUT)
|
||||
offset = config.get(CONF_OFFSET)
|
||||
if timeout is None:
|
||||
timeout = DEFAULT_TIMEOUT
|
||||
|
||||
module_name = None
|
||||
|
||||
@@ -94,7 +93,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
for variable in welcome_sensors:
|
||||
add_devices([NetatmoBinarySensor(
|
||||
data, camera_name, module_name, home, timeout,
|
||||
offset, camera_type, variable)], True)
|
||||
camera_type, variable)], True)
|
||||
if camera_type == 'NOC':
|
||||
if CONF_CAMERAS in config:
|
||||
if config[CONF_CAMERAS] != [] and \
|
||||
@@ -102,14 +101,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
continue
|
||||
for variable in presence_sensors:
|
||||
add_devices([NetatmoBinarySensor(
|
||||
data, camera_name, module_name, home, timeout, offset,
|
||||
data, camera_name, module_name, home, timeout,
|
||||
camera_type, variable)], True)
|
||||
|
||||
for module_name in data.get_module_names(camera_name):
|
||||
for variable in tag_sensors:
|
||||
camera_type = None
|
||||
add_devices([NetatmoBinarySensor(
|
||||
data, camera_name, module_name, home, timeout, offset,
|
||||
data, camera_name, module_name, home, timeout,
|
||||
camera_type, variable)], True)
|
||||
|
||||
|
||||
@@ -117,14 +116,13 @@ class NetatmoBinarySensor(BinarySensorDevice):
|
||||
"""Represent a single binary sensor in a Netatmo Camera device."""
|
||||
|
||||
def __init__(self, data, camera_name, module_name, home,
|
||||
timeout, offset, camera_type, sensor):
|
||||
timeout, camera_type, sensor):
|
||||
"""Set up for access to the Netatmo camera events."""
|
||||
self._data = data
|
||||
self._camera_name = camera_name
|
||||
self._module_name = module_name
|
||||
self._home = home
|
||||
self._timeout = timeout
|
||||
self._offset = offset
|
||||
if home:
|
||||
self._name = '{} / {}'.format(home, camera_name)
|
||||
else:
|
||||
@@ -173,40 +171,39 @@ class NetatmoBinarySensor(BinarySensorDevice):
|
||||
if self._sensor_name == "Someone known":
|
||||
self._state =\
|
||||
self._data.camera_data.someoneKnownSeen(
|
||||
self._home, self._camera_name, self._timeout*60)
|
||||
self._home, self._camera_name, self._timeout)
|
||||
elif self._sensor_name == "Someone unknown":
|
||||
self._state =\
|
||||
self._data.camera_data.someoneUnknownSeen(
|
||||
self._home, self._camera_name, self._timeout*60)
|
||||
self._home, self._camera_name, self._timeout)
|
||||
elif self._sensor_name == "Motion":
|
||||
self._state =\
|
||||
self._data.camera_data.motionDetected(
|
||||
self._home, self._camera_name, self._timeout*60)
|
||||
self._home, self._camera_name, self._timeout)
|
||||
elif self._cameratype == 'NOC':
|
||||
if self._sensor_name == "Outdoor motion":
|
||||
self._state =\
|
||||
self._data.camera_data.outdoormotionDetected(
|
||||
self._home, self._camera_name, self._offset)
|
||||
self._home, self._camera_name, self._timeout)
|
||||
elif self._sensor_name == "Outdoor human":
|
||||
self._state =\
|
||||
self._data.camera_data.humanDetected(
|
||||
self._home, self._camera_name, self._offset)
|
||||
self._home, self._camera_name, self._timeout)
|
||||
elif self._sensor_name == "Outdoor animal":
|
||||
self._state =\
|
||||
self._data.camera_data.animalDetected(
|
||||
self._home, self._camera_name, self._offset)
|
||||
self._home, self._camera_name, self._timeout)
|
||||
elif self._sensor_name == "Outdoor vehicle":
|
||||
self._state =\
|
||||
self._data.camera_data.carDetected(
|
||||
self._home, self._camera_name, self._offset)
|
||||
self._home, self._camera_name, self._timeout)
|
||||
if self._sensor_name == "Tag Vibration":
|
||||
self._state =\
|
||||
self._data.camera_data.moduleMotionDetected(
|
||||
self._home, self._module_name, self._camera_name,
|
||||
self._timeout*60)
|
||||
self._timeout)
|
||||
elif self._sensor_name == "Tag Open":
|
||||
self._state =\
|
||||
self._data.camera_data.moduleOpened(
|
||||
self._home, self._module_name, self._camera_name)
|
||||
else:
|
||||
return None
|
||||
self._home, self._module_name, self._camera_name,
|
||||
self._timeout)
|
||||
|
||||
@@ -59,6 +59,8 @@ class RainCloudBinarySensor(RainCloudEntity, BinarySensorDevice):
|
||||
"""Get the latest data and updates the state."""
|
||||
_LOGGER.debug("Updating RainCloud sensor: %s", self._name)
|
||||
self._state = getattr(self.data, self._sensor_type)
|
||||
if self._sensor_type == 'status':
|
||||
self._state = self._state == 'Online'
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
|
||||
64
homeassistant/components/binary_sensor/random.py
Normal file
64
homeassistant/components/binary_sensor/random.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Support for showing random states.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.random/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA)
|
||||
from homeassistant.const import CONF_NAME, CONF_DEVICE_CLASS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'Random Binary Sensor'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Random binary sensor."""
|
||||
name = config.get(CONF_NAME)
|
||||
device_class = config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
async_add_devices([RandomSensor(name, device_class)], True)
|
||||
|
||||
|
||||
class RandomSensor(BinarySensorDevice):
|
||||
"""Representation of a Random binary sensor."""
|
||||
|
||||
def __init__(self, name, device_class):
|
||||
"""Initialize the Random binary sensor."""
|
||||
self._name = name
|
||||
self._device_class = device_class
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the sensor class of the sensor."""
|
||||
return self._device_class
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
"""Get new state and update the sensor's state."""
|
||||
from random import getrandbits
|
||||
self._state = bool(getrandbits(1))
|
||||
@@ -62,7 +62,6 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
entity[CONF_COMMAND_ON],
|
||||
entity[CONF_COMMAND_OFF])
|
||||
device.hass = hass
|
||||
device.is_lighting4 = (packet_id[2:4] == '13')
|
||||
sensors.append(device)
|
||||
rfxtrx.RFX_DEVICES[device_id] = device
|
||||
|
||||
@@ -86,17 +85,16 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
if not config[ATTR_AUTOMATIC_ADD]:
|
||||
return
|
||||
|
||||
poss_dev = rfxtrx.find_possible_pt2262_device(device_id)
|
||||
|
||||
if poss_dev is not None:
|
||||
poss_id = slugify(poss_dev.event.device.id_string.lower())
|
||||
_LOGGER.info("Found possible matching deviceid %s.",
|
||||
poss_id)
|
||||
if event.device.packettype == 0x13:
|
||||
poss_dev = rfxtrx.find_possible_pt2262_device(device_id)
|
||||
if poss_dev is not None:
|
||||
poss_id = slugify(poss_dev.event.device.id_string.lower())
|
||||
_LOGGER.info("Found possible matching deviceid %s.",
|
||||
poss_id)
|
||||
|
||||
pkt_id = "".join("{0:02x}".format(x) for x in event.data)
|
||||
sensor = RfxtrxBinarySensor(event, pkt_id)
|
||||
sensor.hass = hass
|
||||
sensor.is_lighting4 = (pkt_id[2:4] == '13')
|
||||
rfxtrx.RFX_DEVICES[device_id] = sensor
|
||||
add_devices_callback([sensor])
|
||||
_LOGGER.info("Added binary sensor %s "
|
||||
@@ -114,6 +112,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
slugify(event.device.id_string.lower()),
|
||||
event.device.__class__.__name__,
|
||||
event.device.subtype)
|
||||
|
||||
if sensor.is_lighting4:
|
||||
if sensor.data_bits is not None:
|
||||
cmd = rfxtrx.get_pt2262_cmd(device_id, sensor.data_bits)
|
||||
@@ -154,7 +153,7 @@ class RfxtrxBinarySensor(BinarySensorDevice):
|
||||
self._device_class = device_class
|
||||
self._off_delay = off_delay
|
||||
self._state = False
|
||||
self.is_lighting4 = False
|
||||
self.is_lighting4 = (event.device.packettype == 0x13)
|
||||
self.delay_listener = None
|
||||
self._data_bits = data_bits
|
||||
self._cmd_on = cmd_on
|
||||
|
||||
@@ -11,7 +11,7 @@ import voluptuous as vol
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from homeassistant.components.ring import (
|
||||
CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE)
|
||||
CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DATA_RING)
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS)
|
||||
@@ -28,20 +28,20 @@ SCAN_INTERVAL = timedelta(seconds=5)
|
||||
# Sensor types: Name, category, device_class
|
||||
SENSOR_TYPES = {
|
||||
'ding': ['Ding', ['doorbell'], 'occupancy'],
|
||||
'motion': ['Motion', ['doorbell'], 'motion'],
|
||||
'motion': ['Motion', ['doorbell', 'stickup_cams'], 'motion'],
|
||||
}
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE):
|
||||
cv.string,
|
||||
vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
|
||||
vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
|
||||
vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up a sensor for a Ring device."""
|
||||
ring = hass.data.get('ring')
|
||||
ring = hass.data[DATA_RING]
|
||||
|
||||
sensors = []
|
||||
for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
|
||||
@@ -50,6 +50,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
sensors.append(RingBinarySensor(hass,
|
||||
device,
|
||||
sensor_type))
|
||||
|
||||
for device in ring.stickup_cams:
|
||||
if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]:
|
||||
sensors.append(RingBinarySensor(hass,
|
||||
device,
|
||||
sensor_type))
|
||||
add_devices(sensors, True)
|
||||
return True
|
||||
|
||||
|
||||
97
homeassistant/components/binary_sensor/skybell.py
Normal file
97
homeassistant/components/binary_sensor/skybell.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Binary sensor support for the Skybell HD Doorbell.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.skybell/
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.skybell import (
|
||||
DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice)
|
||||
from homeassistant.const import (
|
||||
CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DEPENDENCIES = ['skybell']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
# Sensor types: Name, device_class, event
|
||||
SENSOR_TYPES = {
|
||||
'button': ['Button', 'occupancy', 'device:sensor:button'],
|
||||
'motion': ['Motion', 'motion', 'device:sensor:motion'],
|
||||
}
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE):
|
||||
cv.string,
|
||||
vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
|
||||
vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the platform for a Skybell device."""
|
||||
skybell = hass.data.get(SKYBELL_DOMAIN)
|
||||
|
||||
sensors = []
|
||||
for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
|
||||
for device in skybell.get_devices():
|
||||
sensors.append(SkybellBinarySensor(device, sensor_type))
|
||||
|
||||
add_devices(sensors, True)
|
||||
|
||||
|
||||
class SkybellBinarySensor(SkybellDevice, BinarySensorDevice):
|
||||
"""A binary sensor implementation for Skybell devices."""
|
||||
|
||||
def __init__(self, device, sensor_type):
|
||||
"""Initialize a binary sensor for a Skybell device."""
|
||||
super().__init__(device)
|
||||
self._sensor_type = sensor_type
|
||||
self._name = "{0} {1}".format(self._device.name,
|
||||
SENSOR_TYPES[self._sensor_type][0])
|
||||
self._device_class = SENSOR_TYPES[self._sensor_type][1]
|
||||
self._event = {}
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of the binary sensor."""
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attrs = super().device_state_attributes
|
||||
|
||||
attrs['event_date'] = self._event.get('createdAt')
|
||||
|
||||
return attrs
|
||||
|
||||
def update(self):
|
||||
"""Get the latest data and updates the state."""
|
||||
super().update()
|
||||
|
||||
event = self._device.latest(SENSOR_TYPES[self._sensor_type][2])
|
||||
|
||||
self._state = bool(event and event.get('id') != self._event.get('id'))
|
||||
|
||||
self._event = event
|
||||
34
homeassistant/components/binary_sensor/tellduslive.py
Normal file
34
homeassistant/components/binary_sensor/tellduslive.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Support for binary sensors using Tellstick Net.
|
||||
|
||||
This platform uses the Telldus Live online service.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.tellduslive/
|
||||
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.tellduslive import TelldusLiveEntity
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up Tellstick sensors."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
add_devices(
|
||||
TelldusLiveSensor(hass, binary_sensor)
|
||||
for binary_sensor in discovery_info
|
||||
)
|
||||
|
||||
|
||||
class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice):
|
||||
"""Representation of a Tellstick sensor."""
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
return self.device.is_on
|
||||
@@ -15,13 +15,12 @@ from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASSES_SCHEMA)
|
||||
from homeassistant.const import (
|
||||
ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE,
|
||||
CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START, STATE_ON)
|
||||
CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import async_generate_entity_id
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change, async_track_same_state)
|
||||
from homeassistant.helpers.restore_state import async_get_last_state
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,10 +93,6 @@ class BinarySensorTemplate(BinarySensorDevice):
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
state = yield from async_get_last_state(self.hass, self.entity_id)
|
||||
if state:
|
||||
self._state = state.state == STATE_ON
|
||||
|
||||
@callback
|
||||
def template_bsensor_state_listener(entity, old_state, new_state):
|
||||
"""Handle the target device state changes."""
|
||||
@@ -135,7 +130,7 @@ class BinarySensorTemplate(BinarySensorDevice):
|
||||
return False
|
||||
|
||||
@callback
|
||||
def _async_render(self, *args):
|
||||
def _async_render(self):
|
||||
"""Get the state of template."""
|
||||
try:
|
||||
return self._template.async_render().lower() == 'true'
|
||||
@@ -171,5 +166,5 @@ class BinarySensorTemplate(BinarySensorDevice):
|
||||
|
||||
period = self._delay_on if state else self._delay_off
|
||||
async_track_same_state(
|
||||
self.hass, state, period, set_state, entity_ids=self._entities,
|
||||
async_check_func=self._async_render)
|
||||
self.hass, period, set_state, entity_ids=self._entities,
|
||||
async_check_same_func=lambda *args: self._async_render() == state)
|
||||
|
||||
@@ -30,7 +30,6 @@ class TeslaBinarySensor(TeslaDevice, BinarySensorDevice):
|
||||
def __init__(self, tesla_device, controller, sensor_type):
|
||||
"""Initialisation of binary sensor."""
|
||||
super().__init__(tesla_device, controller)
|
||||
self._name = self.tesla_device.name
|
||||
self._state = False
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id)
|
||||
self._sensor_type = sensor_type
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
A sensor that monitors trands in other components.
|
||||
A sensor that monitors trends in other components.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/sensor.trend/
|
||||
"""
|
||||
import asyncio
|
||||
from collections import deque
|
||||
import logging
|
||||
import math
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -16,21 +18,40 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA,
|
||||
DEVICE_CLASSES_SCHEMA)
|
||||
from homeassistant.const import (
|
||||
ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, STATE_UNKNOWN)
|
||||
ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME,
|
||||
CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME,
|
||||
STATE_UNKNOWN)
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.util import utcnow
|
||||
|
||||
REQUIREMENTS = ['numpy==1.13.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_ATTRIBUTE = 'attribute'
|
||||
ATTR_GRADIENT = 'gradient'
|
||||
ATTR_MIN_GRADIENT = 'min_gradient'
|
||||
ATTR_INVERT = 'invert'
|
||||
ATTR_SAMPLE_DURATION = 'sample_duration'
|
||||
ATTR_SAMPLE_COUNT = 'sample_count'
|
||||
|
||||
CONF_SENSORS = 'sensors'
|
||||
CONF_ATTRIBUTE = 'attribute'
|
||||
CONF_MAX_SAMPLES = 'max_samples'
|
||||
CONF_MIN_GRADIENT = 'min_gradient'
|
||||
CONF_INVERT = 'invert'
|
||||
CONF_SAMPLE_DURATION = 'sample_duration'
|
||||
|
||||
SENSOR_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_ATTRIBUTE): cv.string,
|
||||
vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
|
||||
vol.Optional(CONF_INVERT, default=False): cv.boolean,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_FRIENDLY_NAME): cv.string,
|
||||
vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int,
|
||||
vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float),
|
||||
vol.Optional(CONF_INVERT, default=False): cv.boolean,
|
||||
vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int,
|
||||
})
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
@@ -43,17 +64,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the trend sensors."""
|
||||
sensors = []
|
||||
|
||||
for device, device_config in config[CONF_SENSORS].items():
|
||||
for device_id, device_config in config[CONF_SENSORS].items():
|
||||
entity_id = device_config[ATTR_ENTITY_ID]
|
||||
attribute = device_config.get(CONF_ATTRIBUTE)
|
||||
friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device)
|
||||
device_class = device_config.get(CONF_DEVICE_CLASS)
|
||||
friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device_id)
|
||||
invert = device_config[CONF_INVERT]
|
||||
max_samples = device_config[CONF_MAX_SAMPLES]
|
||||
min_gradient = device_config[CONF_MIN_GRADIENT]
|
||||
sample_duration = device_config[CONF_SAMPLE_DURATION]
|
||||
|
||||
sensors.append(
|
||||
SensorTrend(
|
||||
hass, device, friendly_name, entity_id, attribute,
|
||||
device_class, invert)
|
||||
hass, device_id, friendly_name, entity_id, attribute,
|
||||
device_class, invert, max_samples, min_gradient,
|
||||
sample_duration)
|
||||
)
|
||||
if not sensors:
|
||||
_LOGGER.error("No sensors added")
|
||||
@@ -65,30 +90,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
class SensorTrend(BinarySensorDevice):
|
||||
"""Representation of a trend Sensor."""
|
||||
|
||||
def __init__(self, hass, device_id, friendly_name,
|
||||
target_entity, attribute, device_class, invert):
|
||||
def __init__(self, hass, device_id, friendly_name, entity_id,
|
||||
attribute, device_class, invert, max_samples,
|
||||
min_gradient, sample_duration):
|
||||
"""Initialize the sensor."""
|
||||
self._hass = hass
|
||||
self.entity_id = generate_entity_id(
|
||||
ENTITY_ID_FORMAT, device_id, hass=hass)
|
||||
self._name = friendly_name
|
||||
self._target_entity = target_entity
|
||||
self._entity_id = entity_id
|
||||
self._attribute = attribute
|
||||
self._device_class = device_class
|
||||
self._invert = invert
|
||||
self._sample_duration = sample_duration
|
||||
self._min_gradient = min_gradient
|
||||
self._gradient = None
|
||||
self._state = None
|
||||
self.from_state = None
|
||||
self.to_state = None
|
||||
|
||||
@callback
|
||||
def trend_sensor_state_listener(entity, old_state, new_state):
|
||||
"""Handle the target device state changes."""
|
||||
self.from_state = old_state
|
||||
self.to_state = new_state
|
||||
hass.async_add_job(self.async_update_ha_state(True))
|
||||
|
||||
track_state_change(hass, target_entity,
|
||||
trend_sensor_state_listener)
|
||||
self.samples = deque(maxlen=max_samples)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -105,33 +123,77 @@ class SensorTrend(BinarySensorDevice):
|
||||
"""Return the sensor class of the sensor."""
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes of the sensor."""
|
||||
return {
|
||||
ATTR_ENTITY_ID: self._entity_id,
|
||||
ATTR_FRIENDLY_NAME: self._name,
|
||||
ATTR_INVERT: self._invert,
|
||||
ATTR_GRADIENT: self._gradient,
|
||||
ATTR_MIN_GRADIENT: self._min_gradient,
|
||||
ATTR_SAMPLE_DURATION: self._sample_duration,
|
||||
ATTR_SAMPLE_COUNT: len(self.samples),
|
||||
}
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Complete device setup after being added to hass."""
|
||||
@callback
|
||||
def trend_sensor_state_listener(entity, old_state, new_state):
|
||||
"""Handle state changes on the observed device."""
|
||||
try:
|
||||
if self._attribute:
|
||||
state = new_state.attributes.get(self._attribute)
|
||||
else:
|
||||
state = new_state.state
|
||||
if state != STATE_UNKNOWN:
|
||||
sample = (utcnow().timestamp(), float(state))
|
||||
self.samples.append(sample)
|
||||
self.async_schedule_update_ha_state(True)
|
||||
except (ValueError, TypeError) as ex:
|
||||
_LOGGER.error(ex)
|
||||
|
||||
async_track_state_change(
|
||||
self.hass, self._entity_id,
|
||||
trend_sensor_state_listener)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
"""Get the latest data and update the states."""
|
||||
if self.from_state is None or self.to_state is None:
|
||||
return
|
||||
if (self.from_state.state == STATE_UNKNOWN or
|
||||
self.to_state.state == STATE_UNKNOWN):
|
||||
return
|
||||
try:
|
||||
if self._attribute:
|
||||
from_value = float(
|
||||
self.from_state.attributes.get(self._attribute))
|
||||
to_value = float(
|
||||
self.to_state.attributes.get(self._attribute))
|
||||
else:
|
||||
from_value = float(self.from_state.state)
|
||||
to_value = float(self.to_state.state)
|
||||
# Remove outdated samples
|
||||
if self._sample_duration > 0:
|
||||
cutoff = utcnow().timestamp() - self._sample_duration
|
||||
while self.samples and self.samples[0][0] < cutoff:
|
||||
self.samples.popleft()
|
||||
|
||||
self._state = to_value > from_value
|
||||
if self._invert:
|
||||
self._state = not self._state
|
||||
if len(self.samples) < 2:
|
||||
return
|
||||
|
||||
except (ValueError, TypeError) as ex:
|
||||
self._state = None
|
||||
_LOGGER.error(ex)
|
||||
# Calculate gradient of linear trend
|
||||
yield from self.hass.async_add_job(self._calculate_gradient)
|
||||
|
||||
# Update state
|
||||
self._state = (
|
||||
abs(self._gradient) > abs(self._min_gradient) and
|
||||
math.copysign(self._gradient, self._min_gradient) == self._gradient
|
||||
)
|
||||
|
||||
if self._invert:
|
||||
self._state = not self._state
|
||||
|
||||
def _calculate_gradient(self):
|
||||
"""Compute the linear trend gradient of the current samples.
|
||||
|
||||
This need run inside executor.
|
||||
"""
|
||||
import numpy as np
|
||||
timestamps = np.array([t for t, _ in self.samples])
|
||||
values = np.array([s for _, s in self.samples])
|
||||
coeffs = np.polyfit(timestamps, values, 1)
|
||||
self._gradient = coeffs[0]
|
||||
|
||||
@@ -9,7 +9,6 @@ import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.wink import WinkDevice, DOMAIN
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,7 +86,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
_LOGGER.info("Device isn't a sensor, skipping")
|
||||
|
||||
|
||||
class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity):
|
||||
class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice):
|
||||
"""Representation of a Wink binary sensor."""
|
||||
|
||||
def __init__(self, wink, hass):
|
||||
@@ -117,6 +116,11 @@ class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity):
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return SENSOR_TYPES.get(self.capability)
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return super().device_state_attributes
|
||||
|
||||
|
||||
class WinkSmokeDetector(WinkBinarySensorDevice):
|
||||
"""Representation of a Wink Smoke detector."""
|
||||
@@ -124,9 +128,9 @@ class WinkSmokeDetector(WinkBinarySensorDevice):
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
'test_activated': self.wink.test_activated()
|
||||
}
|
||||
_attributes = super().device_state_attributes
|
||||
_attributes['test_activated'] = self.wink.test_activated()
|
||||
return _attributes
|
||||
|
||||
|
||||
class WinkHub(WinkBinarySensorDevice):
|
||||
@@ -135,11 +139,11 @@ class WinkHub(WinkBinarySensorDevice):
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
'update_needed': self.wink.update_needed(),
|
||||
'firmware_version': self.wink.firmware_version(),
|
||||
'pairing_mode': self.wink.pairing_mode()
|
||||
}
|
||||
_attributes = super().device_state_attributes
|
||||
_attributes['update_needed'] = self.wink.update_needed()
|
||||
_attributes['firmware_version'] = self.wink.firmware_version()
|
||||
_attributes['pairing_mode'] = self.wink.pairing_mode()
|
||||
return _attributes
|
||||
|
||||
|
||||
class WinkRemote(WinkBinarySensorDevice):
|
||||
@@ -148,12 +152,12 @@ class WinkRemote(WinkBinarySensorDevice):
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
'button_on_pressed': self.wink.button_on_pressed(),
|
||||
'button_off_pressed': self.wink.button_off_pressed(),
|
||||
'button_up_pressed': self.wink.button_up_pressed(),
|
||||
'button_down_pressed': self.wink.button_down_pressed()
|
||||
}
|
||||
_attributes = super().device_state_attributes
|
||||
_attributes['button_on_pressed'] = self.wink.button_on_pressed()
|
||||
_attributes['button_off_pressed'] = self.wink.button_off_pressed()
|
||||
_attributes['button_up_pressed'] = self.wink.button_up_pressed()
|
||||
_attributes['button_down_pressed'] = self.wink.button_down_pressed()
|
||||
return _attributes
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
@@ -167,10 +171,10 @@ class WinkButton(WinkBinarySensorDevice):
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
'pressed': self.wink.pressed(),
|
||||
'long_pressed': self.wink.long_pressed()
|
||||
}
|
||||
_attributes = super().device_state_attributes
|
||||
_attributes['pressed'] = self.wink.pressed()
|
||||
_attributes['long_pressed'] = self.wink.long_pressed()
|
||||
return _attributes
|
||||
|
||||
|
||||
class WinkGang(WinkBinarySensorDevice):
|
||||
|
||||
@@ -12,6 +12,7 @@ ATTR_OPEN_SINCE = 'Open since'
|
||||
|
||||
MOTION = 'motion'
|
||||
NO_MOTION = 'no_motion'
|
||||
ATTR_LAST_ACTION = 'last_action'
|
||||
ATTR_NO_MOTION_SINCE = 'No motion since'
|
||||
|
||||
DENSITY = 'density'
|
||||
@@ -24,13 +25,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items():
|
||||
for device in gateway.devices['binary_sensor']:
|
||||
model = device['model']
|
||||
if model == 'motion':
|
||||
if model in ['motion', 'sensor_motion.aq2']:
|
||||
devices.append(XiaomiMotionSensor(device, hass, gateway))
|
||||
elif model == 'sensor_motion.aq2':
|
||||
devices.append(XiaomiMotionSensor(device, hass, gateway))
|
||||
elif model == 'magnet':
|
||||
devices.append(XiaomiDoorSensor(device, gateway))
|
||||
elif model == 'sensor_magnet.aq2':
|
||||
elif model in ['magnet', 'sensor_magnet.aq2']:
|
||||
devices.append(XiaomiDoorSensor(device, gateway))
|
||||
elif model == 'sensor_wleak.aq1':
|
||||
devices.append(XiaomiWaterLeakSensor(device, gateway))
|
||||
@@ -38,10 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
devices.append(XiaomiSmokeSensor(device, gateway))
|
||||
elif model == 'natgas':
|
||||
devices.append(XiaomiNatgasSensor(device, gateway))
|
||||
elif model == 'switch':
|
||||
devices.append(XiaomiButton(device, 'Switch', 'status',
|
||||
hass, gateway))
|
||||
elif model == 'sensor_switch.aq2':
|
||||
elif model in ['switch', 'sensor_switch.aq2', 'sensor_switch.aq3']:
|
||||
devices.append(XiaomiButton(device, 'Switch', 'status',
|
||||
hass, gateway))
|
||||
elif model == '86sw1':
|
||||
@@ -288,9 +282,17 @@ class XiaomiButton(XiaomiBinarySensor):
|
||||
def __init__(self, device, name, data_key, hass, xiaomi_hub):
|
||||
"""Initialize the XiaomiButton."""
|
||||
self._hass = hass
|
||||
self._last_action = None
|
||||
XiaomiBinarySensor.__init__(self, device, name, xiaomi_hub,
|
||||
data_key, None)
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attrs = {ATTR_LAST_ACTION: self._last_action}
|
||||
attrs.update(super().device_state_attributes)
|
||||
return attrs
|
||||
|
||||
def parse_data(self, data):
|
||||
"""Parse data sent by gateway."""
|
||||
value = data.get(self._data_key)
|
||||
@@ -316,6 +318,8 @@ class XiaomiButton(XiaomiBinarySensor):
|
||||
'entity_id': self.entity_id,
|
||||
'click_type': click_type
|
||||
})
|
||||
self._last_action = click_type
|
||||
|
||||
if value in ['long_click_press', 'long_click_release']:
|
||||
return True
|
||||
return False
|
||||
@@ -327,10 +331,18 @@ class XiaomiCube(XiaomiBinarySensor):
|
||||
def __init__(self, device, hass, xiaomi_hub):
|
||||
"""Initialize the Xiaomi Cube."""
|
||||
self._hass = hass
|
||||
self._last_action = None
|
||||
self._state = False
|
||||
XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub,
|
||||
None, None)
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attrs = {ATTR_LAST_ACTION: self._last_action}
|
||||
attrs.update(super().device_state_attributes)
|
||||
return attrs
|
||||
|
||||
def parse_data(self, data):
|
||||
"""Parse data sent by gateway."""
|
||||
if 'status' in data:
|
||||
@@ -338,6 +350,7 @@ class XiaomiCube(XiaomiBinarySensor):
|
||||
'entity_id': self.entity_id,
|
||||
'action_type': data['status']
|
||||
})
|
||||
self._last_action = data['status']
|
||||
|
||||
if 'rotate' in data:
|
||||
self._hass.bus.fire('cube_action', {
|
||||
@@ -345,4 +358,6 @@ class XiaomiCube(XiaomiBinarySensor):
|
||||
'action_type': 'rotate',
|
||||
'action_value': float(data['rotate'].replace(",", "."))
|
||||
})
|
||||
return False
|
||||
self._last_action = 'rotate'
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
# Describes the format for available calendar services
|
||||
|
||||
todoist:
|
||||
new_task:
|
||||
description: Create a new task and add it to a project.
|
||||
fields:
|
||||
content:
|
||||
description: The name of the task. [Required]
|
||||
description: The name of the task (Required).
|
||||
example: Pick up the mail
|
||||
project:
|
||||
description: The name of the project this task should belong to. Defaults to Inbox. [Optional]
|
||||
description: The name of the project this task should belong to. Defaults to Inbox (Optional).
|
||||
example: Errands
|
||||
labels:
|
||||
description: Any labels that you want to apply to this task, separated by a comma. [Optional]
|
||||
description: Any labels that you want to apply to this task, separated by a comma (Optional).
|
||||
example: Chores,Deliveries
|
||||
priority:
|
||||
description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional]
|
||||
description: The priority of this task, from 1 (normal) to 4 (urgent) (Optional).
|
||||
example: 2
|
||||
due_date:
|
||||
description: The day this task is due, in format YYYY-MM-DD. [Optional]
|
||||
description: The day this task is due, in format YYYY-MM-DD (Optional).
|
||||
example: "2018-04-01"
|
||||
|
||||
@@ -29,18 +29,22 @@ from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
|
||||
from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DOMAIN = 'camera'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_EN_MOTION = 'enable_motion_detection'
|
||||
SERVICE_DISEN_MOTION = 'disable_motion_detection'
|
||||
DOMAIN = 'camera'
|
||||
DEPENDENCIES = ['http']
|
||||
SERVICE_ENABLE_MOTION = 'enable_motion_detection'
|
||||
SERVICE_DISABLE_MOTION = 'disable_motion_detection'
|
||||
SERVICE_SNAPSHOT = 'snapshot'
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
ATTR_FILENAME = 'filename'
|
||||
|
||||
STATE_RECORDING = 'recording'
|
||||
STATE_STREAMING = 'streaming'
|
||||
STATE_IDLE = 'idle'
|
||||
@@ -55,13 +59,17 @@ CAMERA_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
})
|
||||
|
||||
CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
|
||||
vol.Required(ATTR_FILENAME): cv.template
|
||||
})
|
||||
|
||||
|
||||
@bind_hass
|
||||
def enable_motion_detection(hass, entity_id=None):
|
||||
"""Enable Motion Detection."""
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
|
||||
hass.async_add_job(hass.services.async_call(
|
||||
DOMAIN, SERVICE_EN_MOTION, data))
|
||||
DOMAIN, SERVICE_ENABLE_MOTION, data))
|
||||
|
||||
|
||||
@bind_hass
|
||||
@@ -69,9 +77,20 @@ def disable_motion_detection(hass, entity_id=None):
|
||||
"""Disable Motion Detection."""
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
|
||||
hass.async_add_job(hass.services.async_call(
|
||||
DOMAIN, SERVICE_DISEN_MOTION, data))
|
||||
DOMAIN, SERVICE_DISABLE_MOTION, data))
|
||||
|
||||
|
||||
@bind_hass
|
||||
def async_snapshot(hass, filename, entity_id=None):
|
||||
"""Make a snapshot from a camera."""
|
||||
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
|
||||
data[ATTR_FILENAME] = filename
|
||||
|
||||
hass.async_add_job(hass.services.async_call(
|
||||
DOMAIN, SERVICE_SNAPSHOT, data))
|
||||
|
||||
|
||||
@bind_hass
|
||||
@asyncio.coroutine
|
||||
def async_get_image(hass, entity_id, timeout=10):
|
||||
"""Fetch a image from a camera entity."""
|
||||
@@ -119,44 +138,72 @@ def async_setup(hass, config):
|
||||
entity.async_update_token()
|
||||
hass.async_add_job(entity.async_update_ha_state())
|
||||
|
||||
async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL)
|
||||
hass.helpers.event.async_track_time_interval(
|
||||
update_tokens, TOKEN_CHANGE_INTERVAL)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_camera_service(service):
|
||||
"""Handle calls to the camera services."""
|
||||
target_cameras = component.async_extract_from_service(service)
|
||||
|
||||
for camera in target_cameras:
|
||||
if service.service == SERVICE_EN_MOTION:
|
||||
yield from camera.async_enable_motion_detection()
|
||||
elif service.service == SERVICE_DISEN_MOTION:
|
||||
yield from camera.async_disable_motion_detection()
|
||||
|
||||
update_tasks = []
|
||||
for camera in target_cameras:
|
||||
if service.service == SERVICE_ENABLE_MOTION:
|
||||
yield from camera.async_enable_motion_detection()
|
||||
elif service.service == SERVICE_DISABLE_MOTION:
|
||||
yield from camera.async_disable_motion_detection()
|
||||
|
||||
if not camera.should_poll:
|
||||
continue
|
||||
|
||||
update_coro = hass.async_add_job(
|
||||
camera.async_update_ha_state(True))
|
||||
if hasattr(camera, 'async_update'):
|
||||
update_tasks.append(update_coro)
|
||||
else:
|
||||
yield from update_coro
|
||||
update_tasks.append(camera.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_snapshot_service(service):
|
||||
"""Handle snapshot services calls."""
|
||||
target_cameras = component.async_extract_from_service(service)
|
||||
filename = service.data[ATTR_FILENAME]
|
||||
filename.hass = hass
|
||||
|
||||
for camera in target_cameras:
|
||||
snapshot_file = filename.async_render(
|
||||
variables={ATTR_ENTITY_ID: camera})
|
||||
|
||||
# check if we allow to access to that file
|
||||
if not hass.config.is_allowed_path(snapshot_file):
|
||||
_LOGGER.error(
|
||||
"Can't write %s, no access to path!", snapshot_file)
|
||||
continue
|
||||
|
||||
image = yield from camera.async_camera_image()
|
||||
|
||||
def _write_image(to_file, image_data):
|
||||
"""Executor helper to write image."""
|
||||
with open(to_file, 'wb') as img_file:
|
||||
img_file.write(image_data)
|
||||
|
||||
try:
|
||||
yield from hass.async_add_job(
|
||||
_write_image, snapshot_file, image)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write image to file: %s", err)
|
||||
|
||||
descriptions = yield from hass.async_add_job(
|
||||
load_yaml_config_file, os.path.join(
|
||||
os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_EN_MOTION, async_handle_camera_service,
|
||||
descriptions.get(SERVICE_EN_MOTION), schema=CAMERA_SERVICE_SCHEMA)
|
||||
DOMAIN, SERVICE_ENABLE_MOTION, async_handle_camera_service,
|
||||
descriptions.get(SERVICE_ENABLE_MOTION), schema=CAMERA_SERVICE_SCHEMA)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_DISEN_MOTION, async_handle_camera_service,
|
||||
descriptions.get(SERVICE_DISEN_MOTION), schema=CAMERA_SERVICE_SCHEMA)
|
||||
DOMAIN, SERVICE_DISABLE_MOTION, async_handle_camera_service,
|
||||
descriptions.get(SERVICE_DISABLE_MOTION), schema=CAMERA_SERVICE_SCHEMA)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SNAPSHOT, async_handle_snapshot_service,
|
||||
descriptions.get(SERVICE_SNAPSHOT),
|
||||
schema=CAMERA_SERVICE_SNAPSHOT)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,37 +1,40 @@
|
||||
"""
|
||||
This component provides basic support for Netgear Arlo IP cameras.
|
||||
Support for Netgear Arlo IP cameras.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.arlo/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO
|
||||
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
|
||||
from homeassistant.components.ffmpeg import DATA_FFMPEG
|
||||
from homeassistant.const import ATTR_BATTERY_LEVEL
|
||||
|
||||
DEPENDENCIES = ['arlo', 'ffmpeg']
|
||||
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
ARLO_MODE_ARMED = 'armed'
|
||||
ARLO_MODE_DISARMED = 'disarmed'
|
||||
|
||||
ATTR_BRIGHTNESS = 'brightness'
|
||||
ATTR_FLIPPED = 'flipped'
|
||||
ATTR_MIRRORED = 'mirrored'
|
||||
ATTR_MOTION_SENSITIVITY = 'motion_detection_sensitivity'
|
||||
ATTR_POWER_SAVE_MODE = 'power_save_mode'
|
||||
ATTR_MOTION = 'motion_detection_sensitivity'
|
||||
ATTR_POWERSAVE = 'power_save_mode'
|
||||
ATTR_SIGNAL_STRENGTH = 'signal_strength'
|
||||
ATTR_UNSEEN_VIDEOS = 'unseen_videos'
|
||||
|
||||
CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
|
||||
|
||||
ARLO_MODE_ARMED = 'armed'
|
||||
ARLO_MODE_DISARMED = 'disarmed'
|
||||
DEPENDENCIES = ['arlo', 'ffmpeg']
|
||||
|
||||
POWERSAVE_MODE_MAPPING = {
|
||||
1: 'best_battery_life',
|
||||
@@ -40,7 +43,8 @@ POWERSAVE_MODE_MAPPING = {
|
||||
}
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string,
|
||||
vol.Optional(CONF_FFMPEG_ARGUMENTS):
|
||||
cv.string,
|
||||
})
|
||||
|
||||
|
||||
@@ -69,6 +73,7 @@ class ArloCam(Camera):
|
||||
self._motion_status = False
|
||||
self._ffmpeg = hass.data[DATA_FFMPEG]
|
||||
self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS)
|
||||
self.attrs = {}
|
||||
|
||||
def camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
@@ -100,32 +105,24 @@ class ArloCam(Camera):
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_BATTERY_LEVEL:
|
||||
self._camera.get_battery_level,
|
||||
ATTR_BRIGHTNESS:
|
||||
self._camera.get_brightness,
|
||||
ATTR_FLIPPED:
|
||||
self._camera.get_flip_state,
|
||||
ATTR_MIRRORED:
|
||||
self._camera.get_mirror_state,
|
||||
ATTR_MOTION_SENSITIVITY:
|
||||
self._camera.get_motion_detection_sensitivity,
|
||||
ATTR_POWER_SAVE_MODE:
|
||||
POWERSAVE_MODE_MAPPING[self._camera.get_powersave_mode],
|
||||
ATTR_SIGNAL_STRENGTH:
|
||||
self._camera.get_signal_strength,
|
||||
ATTR_UNSEEN_VIDEOS:
|
||||
self._camera.unseen_videos
|
||||
ATTR_BATTERY_LEVEL: self.attrs.get(ATTR_BATTERY_LEVEL),
|
||||
ATTR_BRIGHTNESS: self.attrs.get(ATTR_BRIGHTNESS),
|
||||
ATTR_FLIPPED: self.attrs.get(ATTR_FLIPPED),
|
||||
ATTR_MIRRORED: self.attrs.get(ATTR_MIRRORED),
|
||||
ATTR_MOTION: self.attrs.get(ATTR_MOTION),
|
||||
ATTR_POWERSAVE: self.attrs.get(ATTR_POWERSAVE),
|
||||
ATTR_SIGNAL_STRENGTH: self.attrs.get(ATTR_SIGNAL_STRENGTH),
|
||||
ATTR_UNSEEN_VIDEOS: self.attrs.get(ATTR_UNSEEN_VIDEOS),
|
||||
}
|
||||
|
||||
@property
|
||||
def model(self):
|
||||
"""Camera model."""
|
||||
"""Return the camera model."""
|
||||
return self._camera.model_id
|
||||
|
||||
@property
|
||||
def brand(self):
|
||||
"""Camera brand."""
|
||||
"""Return the camera brand."""
|
||||
return DEFAULT_BRAND
|
||||
|
||||
@property
|
||||
@@ -135,7 +132,7 @@ class ArloCam(Camera):
|
||||
|
||||
@property
|
||||
def motion_detection_enabled(self):
|
||||
"""Camera Motion Detection Status."""
|
||||
"""Return the camera motion detection status."""
|
||||
return self._motion_status
|
||||
|
||||
def set_base_station_mode(self, mode):
|
||||
@@ -143,7 +140,7 @@ class ArloCam(Camera):
|
||||
# Get the list of base stations identified by library
|
||||
base_stations = self.hass.data[DATA_ARLO].base_stations
|
||||
|
||||
# Some Arlo cameras does not have basestation
|
||||
# Some Arlo cameras does not have base station
|
||||
# So check if there is base station detected first
|
||||
# if yes, then choose the primary base station
|
||||
# Set the mode on the chosen base station
|
||||
@@ -160,3 +157,16 @@ class ArloCam(Camera):
|
||||
"""Disable the motion detection in base station (Disarm)."""
|
||||
self._motion_status = False
|
||||
self.set_base_station_mode(ARLO_MODE_DISARMED)
|
||||
|
||||
def update(self):
|
||||
"""Add an attribute-update task to the executor pool."""
|
||||
self.attrs[ATTR_BATTERY_LEVEL] = self._camera.get_battery_level
|
||||
self.attrs[ATTR_BRIGHTNESS] = self._camera.get_battery_level
|
||||
self.attrs[ATTR_FLIPPED] = self._camera.get_flip_state,
|
||||
self.attrs[ATTR_MIRRORED] = self._camera.get_mirror_state,
|
||||
self.attrs[
|
||||
ATTR_MOTION] = self._camera.get_motion_detection_sensitivity,
|
||||
self.attrs[ATTR_POWERSAVE] = POWERSAVE_MODE_MAPPING[
|
||||
self._camera.get_powersave_mode],
|
||||
self.attrs[ATTR_SIGNAL_STRENGTH] = self._camera.get_signal_strength,
|
||||
self.attrs[ATTR_UNSEEN_VIDEOS] = self._camera.unseen_videos
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.const import (
|
||||
CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
|
||||
from homeassistant.components.camera.mjpeg import (
|
||||
CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.dispatcher import dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,9 +52,9 @@ class AxisCamera(MjpegCamera):
|
||||
"""Initialize Axis Communications camera component."""
|
||||
super().__init__(hass, config)
|
||||
self.port = port
|
||||
async_dispatcher_connect(hass,
|
||||
DOMAIN + '_' + config[CONF_NAME] + '_new_ip',
|
||||
self._new_ip)
|
||||
dispatcher_connect(hass,
|
||||
DOMAIN + '_' + config[CONF_NAME] + '_new_ip',
|
||||
self._new_ip)
|
||||
|
||||
def _new_ip(self, host):
|
||||
"""Set new IP for video stream."""
|
||||
|
||||
@@ -55,9 +55,9 @@ class FFmpegCamera(Camera):
|
||||
from haffmpeg import ImageFrame, IMAGE_JPEG
|
||||
ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop)
|
||||
|
||||
image = yield from ffmpeg.get_image(
|
||||
image = yield from asyncio.shield(ffmpeg.get_image(
|
||||
self._input, output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._extra_arguments)
|
||||
extra_cmd=self._extra_arguments), loop=self.hass.loop)
|
||||
return image
|
||||
|
||||
@asyncio.coroutine
|
||||
|
||||
@@ -78,9 +78,9 @@ class ONVIFCamera(Camera):
|
||||
ffmpeg = ImageFrame(
|
||||
self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop)
|
||||
|
||||
image = yield from ffmpeg.get_image(
|
||||
image = yield from asyncio.shield(ffmpeg.get_image(
|
||||
self._input, output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._ffmpeg_arguments)
|
||||
extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop)
|
||||
return image
|
||||
|
||||
@asyncio.coroutine
|
||||
|
||||
143
homeassistant/components/camera/ring.py
Normal file
143
homeassistant/components/camera/ring.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
This component provides support to the Ring Door Bell camera.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.ring/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.components.ring import DATA_RING, CONF_ATTRIBUTION
|
||||
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
|
||||
from homeassistant.components.ffmpeg import DATA_FFMPEG
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, CONF_SCAN_INTERVAL
|
||||
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
|
||||
|
||||
DEPENDENCIES = ['ring', 'ffmpeg']
|
||||
|
||||
FORCE_REFRESH_INTERVAL = timedelta(minutes=45)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=90)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string,
|
||||
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
|
||||
cv.time_period,
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up a Ring Door Bell and StickUp Camera."""
|
||||
ring = hass.data[DATA_RING]
|
||||
|
||||
cams = []
|
||||
for camera in ring.doorbells:
|
||||
cams.append(RingCam(hass, camera, config))
|
||||
|
||||
for camera in ring.stickup_cams:
|
||||
cams.append(RingCam(hass, camera, config))
|
||||
|
||||
async_add_devices(cams, True)
|
||||
return True
|
||||
|
||||
|
||||
class RingCam(Camera):
|
||||
"""An implementation of a Ring Door Bell camera."""
|
||||
|
||||
def __init__(self, hass, camera, device_info):
|
||||
"""Initialize a Ring Door Bell camera."""
|
||||
super(RingCam, self).__init__()
|
||||
self._camera = camera
|
||||
self._hass = hass
|
||||
self._name = self._camera.name
|
||||
self._ffmpeg = hass.data[DATA_FFMPEG]
|
||||
self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS)
|
||||
self._last_video_id = self._camera.last_recording_id
|
||||
self._video_url = self._camera.recording_url(self._last_video_id)
|
||||
self._utcnow = dt_util.utcnow()
|
||||
self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this camera."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
|
||||
'device_id': self._camera.id,
|
||||
'firmware': self._camera.firmware,
|
||||
'kind': self._camera.kind,
|
||||
'timezone': self._camera.timezone,
|
||||
'type': self._camera.family,
|
||||
'video_url': self._video_url,
|
||||
'video_id': self._last_video_id
|
||||
}
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
from haffmpeg import ImageFrame, IMAGE_JPEG
|
||||
ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop)
|
||||
|
||||
if self._video_url is None:
|
||||
return
|
||||
|
||||
image = yield from asyncio.shield(ffmpeg.get_image(
|
||||
self._video_url, output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop)
|
||||
return image
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
from haffmpeg import CameraMjpeg
|
||||
|
||||
if self._video_url is None:
|
||||
return
|
||||
|
||||
stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
|
||||
yield from stream.open_camera(
|
||||
self._video_url, extra_cmd=self._ffmpeg_arguments)
|
||||
|
||||
yield from async_aiohttp_proxy_stream(
|
||||
self.hass, request, stream,
|
||||
'multipart/x-mixed-replace;boundary=ffserver')
|
||||
yield from stream.close()
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Update the image periodically."""
|
||||
return True
|
||||
|
||||
def update(self):
|
||||
"""Update camera entity and refresh attributes."""
|
||||
_LOGGER.debug("Checking if Ring DoorBell needs to refresh video_url")
|
||||
|
||||
self._camera.update()
|
||||
self._utcnow = dt_util.utcnow()
|
||||
|
||||
last_recording_id = self._camera.last_recording_id
|
||||
|
||||
if self._last_video_id != last_recording_id or \
|
||||
self._utcnow >= self._expires_at:
|
||||
|
||||
_LOGGER.info("Ring DoorBell properties refreshed")
|
||||
|
||||
# update attributes if new video or if URL has expired
|
||||
self._last_video_id = self._camera.last_recording_id
|
||||
self._video_url = self._camera.recording_url(self._last_video_id)
|
||||
self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow
|
||||
@@ -1,17 +1,25 @@
|
||||
# Describes the format for available camera services
|
||||
|
||||
enable_motion_detection:
|
||||
description: Enable the motion detection in a camera
|
||||
|
||||
description: Enable the motion detection in a camera.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to enable motion detection
|
||||
description: Name(s) of entities to enable motion detection.
|
||||
example: 'camera.living_room_camera'
|
||||
|
||||
disable_motion_detection:
|
||||
description: Disable the motion detection in a camera
|
||||
|
||||
description: Disable the motion detection in a camera.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to disable motion detection
|
||||
description: Name(s) of entities to disable motion detection.
|
||||
example: 'camera.living_room_camera'
|
||||
|
||||
snapshot:
|
||||
description: Take a snapshot from a camera.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to create snapshots from.
|
||||
example: 'camera.living_room_camera'
|
||||
filename:
|
||||
description: Template of a Filename. Variable is entity_id.
|
||||
example: '/tmp/snapshot_{{ entity_id }}'
|
||||
|
||||
67
homeassistant/components/camera/skybell.py
Normal file
67
homeassistant/components/camera/skybell.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Camera support for the Skybell HD Doorbell.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.skybell/
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.components.skybell import (
|
||||
DOMAIN as SKYBELL_DOMAIN, SkybellDevice)
|
||||
|
||||
DEPENDENCIES = ['skybell']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=90)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the platform for a Skybell device."""
|
||||
skybell = hass.data.get(SKYBELL_DOMAIN)
|
||||
|
||||
sensors = []
|
||||
for device in skybell.get_devices():
|
||||
sensors.append(SkybellCamera(device))
|
||||
|
||||
add_devices(sensors, True)
|
||||
|
||||
|
||||
class SkybellCamera(SkybellDevice, Camera):
|
||||
"""A camera implementation for Skybell devices."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""Initialize a camera for a Skybell device."""
|
||||
SkybellDevice.__init__(self, device)
|
||||
Camera.__init__(self)
|
||||
self._name = self._device.name
|
||||
self._url = None
|
||||
self._response = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
def camera_image(self):
|
||||
"""Get the latest camera image."""
|
||||
super().update()
|
||||
|
||||
if self._url != self._device.image:
|
||||
self._url = self._device.image
|
||||
|
||||
try:
|
||||
self._response = requests.get(
|
||||
self._url, stream=True, timeout=10)
|
||||
except requests.HTTPError as err:
|
||||
_LOGGER.warning("Failed to get camera image: %s", err)
|
||||
self._response = None
|
||||
|
||||
if not self._response:
|
||||
return None
|
||||
|
||||
return self._response.content
|
||||
@@ -16,11 +16,11 @@ from homeassistant.const import (
|
||||
from homeassistant.components.camera import (
|
||||
Camera, PLATFORM_SCHEMA)
|
||||
from homeassistant.helpers.aiohttp_client import (
|
||||
async_create_clientsession,
|
||||
async_aiohttp_proxy_web)
|
||||
async_aiohttp_proxy_web,
|
||||
async_get_clientsession)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['py-synology==0.1.3']
|
||||
REQUIREMENTS = ['py-synology==0.1.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,13 +58,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
return False
|
||||
|
||||
cameras = surveillance.get_all_cameras()
|
||||
websession = async_create_clientsession(hass, verify_ssl)
|
||||
|
||||
# add cameras
|
||||
devices = []
|
||||
for camera in cameras:
|
||||
if not config.get(CONF_WHITELIST):
|
||||
device = SynologyCamera(websession, surveillance, camera.camera_id)
|
||||
device = SynologyCamera(surveillance, camera.camera_id, verify_ssl)
|
||||
devices.append(device)
|
||||
|
||||
async_add_devices(devices)
|
||||
@@ -73,12 +72,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
class SynologyCamera(Camera):
|
||||
"""An implementation of a Synology NAS based IP camera."""
|
||||
|
||||
def __init__(self, websession, surveillance, camera_id):
|
||||
def __init__(self, surveillance, camera_id, verify_ssl):
|
||||
"""Initialize a Synology Surveillance Station camera."""
|
||||
super().__init__()
|
||||
self._websession = websession
|
||||
self._surveillance = surveillance
|
||||
self._camera_id = camera_id
|
||||
self._verify_ssl = verify_ssl
|
||||
self._camera = self._surveillance.get_camera(camera_id)
|
||||
self._motion_setting = self._surveillance.get_motion_setting(camera_id)
|
||||
self.is_streaming = self._camera.is_enabled
|
||||
@@ -91,7 +90,9 @@ class SynologyCamera(Camera):
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
"""Return a MJPEG stream image response directly from the camera."""
|
||||
streaming_url = self._camera.video_stream_url
|
||||
stream_coro = self._websession.get(streaming_url)
|
||||
|
||||
websession = async_get_clientsession(self.hass, self._verify_ssl)
|
||||
stream_coro = websession.get(streaming_url)
|
||||
|
||||
yield from async_aiohttp_proxy_web(self.hass, request, stream_coro)
|
||||
|
||||
|
||||
137
homeassistant/components/camera/yi.py
Normal file
137
homeassistant/components/camera/yi.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
This component provides support for Xiaomi Cameras (HiSilicon Hi3518e V200).
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.yi/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
|
||||
from homeassistant.components.ffmpeg import DATA_FFMPEG
|
||||
from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PATH,
|
||||
CONF_PASSWORD, CONF_PORT, CONF_USERNAME)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
||||
|
||||
DEPENDENCIES = ['ffmpeg']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BRAND = 'YI Home Camera'
|
||||
DEFAULT_PASSWORD = ''
|
||||
DEFAULT_PATH = '/tmp/sd/record'
|
||||
DEFAULT_PORT = 21
|
||||
DEFAULT_USERNAME = 'root'
|
||||
|
||||
CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string,
|
||||
vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
|
||||
vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up a Yi Camera."""
|
||||
_LOGGER.debug('Received configuration: %s', config)
|
||||
async_add_devices([YiCamera(hass, config)], True)
|
||||
|
||||
|
||||
class YiCamera(Camera):
|
||||
"""Define an implementation of a Yi Camera."""
|
||||
|
||||
def __init__(self, hass, config):
|
||||
"""Initialize."""
|
||||
super().__init__()
|
||||
self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS)
|
||||
self._last_image = None
|
||||
self._last_url = None
|
||||
self._manager = hass.data[DATA_FFMPEG]
|
||||
self._name = config.get(CONF_NAME)
|
||||
self.host = config.get(CONF_HOST)
|
||||
self.port = config.get(CONF_PORT)
|
||||
self.path = config.get(CONF_PATH)
|
||||
self.user = config.get(CONF_USERNAME)
|
||||
self.passwd = config.get(CONF_PASSWORD)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this camera."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def brand(self):
|
||||
"""Camera brand."""
|
||||
return DEFAULT_BRAND
|
||||
|
||||
def get_latest_video_url(self):
|
||||
"""Retrieve the latest video file from the customized Yi FTP server."""
|
||||
from ftplib import FTP, error_perm
|
||||
|
||||
ftp = FTP(self.host)
|
||||
try:
|
||||
ftp.login(self.user, self.passwd)
|
||||
except error_perm as exc:
|
||||
_LOGGER.error('There was an error while logging into the camera')
|
||||
_LOGGER.debug(exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
ftp.cwd(self.path)
|
||||
except error_perm as exc:
|
||||
_LOGGER.error('Unable to find path: %s', self.path)
|
||||
_LOGGER.debug(exc)
|
||||
return False
|
||||
|
||||
dirs = [d for d in ftp.nlst() if '.' not in d]
|
||||
if not dirs:
|
||||
_LOGGER.warning("There don't appear to be any uploaded videos")
|
||||
return False
|
||||
|
||||
latest_dir = dirs[-1]
|
||||
ftp.cwd(latest_dir)
|
||||
videos = ftp.nlst()
|
||||
if not videos:
|
||||
_LOGGER.info('Video folder "%s" is empty; delaying', latest_dir)
|
||||
return False
|
||||
|
||||
return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format(
|
||||
self.user, self.passwd, self.host, self.port, self.path,
|
||||
latest_dir, videos[-1])
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
from haffmpeg import ImageFrame, IMAGE_JPEG
|
||||
|
||||
url = yield from self.hass.async_add_job(self.get_latest_video_url)
|
||||
if url != self._last_url:
|
||||
ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop)
|
||||
self._last_image = yield from asyncio.shield(ffmpeg.get_image(
|
||||
url, output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._extra_arguments), loop=self.hass.loop)
|
||||
self._last_url = url
|
||||
|
||||
return self._last_image
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
from haffmpeg import CameraMjpeg
|
||||
|
||||
stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop)
|
||||
yield from stream.open_camera(
|
||||
self._last_url, extra_cmd=self._extra_arguments)
|
||||
|
||||
yield from async_aiohttp_proxy_stream(
|
||||
self.hass, request, stream,
|
||||
'multipart/x-mixed-replace;boundary=ffserver')
|
||||
yield from stream.close()
|
||||
@@ -236,24 +236,6 @@ def async_setup(hass, config):
|
||||
load_yaml_config_file,
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
@asyncio.coroutine
|
||||
def _async_update_climate(target_climate):
|
||||
"""Update climate entity after service stuff."""
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
|
||||
update_coro = hass.async_add_job(
|
||||
climate.async_update_ha_state(True))
|
||||
if hasattr(climate, 'async_update'):
|
||||
update_tasks.append(update_coro)
|
||||
else:
|
||||
yield from update_coro
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_away_mode_set_service(service):
|
||||
"""Set away mode on target climate devices."""
|
||||
@@ -261,13 +243,19 @@ def async_setup(hass, config):
|
||||
|
||||
away_mode = service.data.get(ATTR_AWAY_MODE)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
if away_mode:
|
||||
yield from climate.async_turn_away_mode_on()
|
||||
else:
|
||||
yield from climate.async_turn_away_mode_off()
|
||||
|
||||
yield from _async_update_climate(target_climate)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_AWAY_MODE, async_away_mode_set_service,
|
||||
@@ -281,10 +269,16 @@ def async_setup(hass, config):
|
||||
|
||||
hold_mode = service.data.get(ATTR_HOLD_MODE)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_hold_mode(hold_mode)
|
||||
|
||||
yield from _async_update_climate(target_climate)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service,
|
||||
@@ -298,13 +292,19 @@ def async_setup(hass, config):
|
||||
|
||||
aux_heat = service.data.get(ATTR_AUX_HEAT)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
if aux_heat:
|
||||
yield from climate.async_turn_aux_heat_on()
|
||||
else:
|
||||
yield from climate.async_turn_aux_heat_off()
|
||||
|
||||
yield from _async_update_climate(target_climate)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_AUX_HEAT, async_aux_heat_set_service,
|
||||
@@ -316,6 +316,7 @@ def async_setup(hass, config):
|
||||
"""Set temperature on the target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
kwargs = {}
|
||||
for value, temp in service.data.items():
|
||||
@@ -330,7 +331,12 @@ def async_setup(hass, config):
|
||||
|
||||
yield from climate.async_set_temperature(**kwargs)
|
||||
|
||||
yield from _async_update_climate(target_climate)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_TEMPERATURE, async_temperature_set_service,
|
||||
@@ -344,10 +350,15 @@ def async_setup(hass, config):
|
||||
|
||||
humidity = service.data.get(ATTR_HUMIDITY)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_humidity(humidity)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
yield from _async_update_climate(target_climate)
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_HUMIDITY, async_humidity_set_service,
|
||||
@@ -361,10 +372,15 @@ def async_setup(hass, config):
|
||||
|
||||
fan = service.data.get(ATTR_FAN_MODE)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_fan_mode(fan)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
yield from _async_update_climate(target_climate)
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_FAN_MODE, async_fan_mode_set_service,
|
||||
@@ -378,10 +394,15 @@ def async_setup(hass, config):
|
||||
|
||||
operation_mode = service.data.get(ATTR_OPERATION_MODE)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_operation_mode(operation_mode)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
yield from _async_update_climate(target_climate)
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_OPERATION_MODE, async_operation_set_service,
|
||||
@@ -395,10 +416,15 @@ def async_setup(hass, config):
|
||||
|
||||
swing_mode = service.data.get(ATTR_SWING_MODE)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_swing_mode(swing_mode)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
yield from _async_update_climate(target_climate)
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_SWING_MODE, async_swing_set_service,
|
||||
|
||||
117
homeassistant/components/climate/ephember.py
Normal file
117
homeassistant/components/climate/ephember.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Support for the EPH Controls Ember themostats.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.ephember/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice, PLATFORM_SCHEMA, STATE_HEAT, STATE_IDLE)
|
||||
from homeassistant.const import (
|
||||
TEMP_CELSIUS, CONF_USERNAME, CONF_PASSWORD)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyephember==0.1.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Return cached results if last scan was less then this time ago
|
||||
SCAN_INTERVAL = timedelta(seconds=120)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the ephember thermostat."""
|
||||
from pyephember.pyephember import EphEmber
|
||||
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
|
||||
try:
|
||||
ember = EphEmber(username, password)
|
||||
zones = ember.get_zones()
|
||||
for zone in zones:
|
||||
add_devices([EphEmberThermostat(ember, zone)])
|
||||
except RuntimeError:
|
||||
_LOGGER.error("Cannot connect to EphEmber")
|
||||
return
|
||||
|
||||
return
|
||||
|
||||
|
||||
class EphEmberThermostat(ClimateDevice):
|
||||
"""Representation of a HeatmiserV3 thermostat."""
|
||||
|
||||
def __init__(self, ember, zone):
|
||||
"""Initialize the thermostat."""
|
||||
self._ember = ember
|
||||
self._zone_name = zone['name']
|
||||
self._zone = zone
|
||||
self._hot_water = zone['isHotWater']
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the thermostat, if any."""
|
||||
return self._zone_name
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement which this thermostat uses."""
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._zone['currentTemperature']
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._zone['targetTemperature']
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
if self._zone['isCurrentlyActive']:
|
||||
return STATE_HEAT
|
||||
else:
|
||||
return STATE_IDLE
|
||||
|
||||
@property
|
||||
def is_aux_heat_on(self):
|
||||
"""Return true if aux heater."""
|
||||
return self._zone['isBoostActive']
|
||||
|
||||
def turn_aux_heat_on(self):
|
||||
"""Turn auxiliary heater on."""
|
||||
self._ember.activate_boost_by_name(
|
||||
self._zone_name, self._zone['targetTemperature'])
|
||||
|
||||
def turn_aux_heat_off(self):
|
||||
"""Turn auxiliary heater off."""
|
||||
self._ember.deactivate_boost_by_name(self._zone_name)
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
return
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return self._zone['targetTemperature']
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return self._zone['targetTemperature']
|
||||
|
||||
def update(self):
|
||||
"""Get the latest data."""
|
||||
self._zone = self._ember.get_zone(self._zone_name)
|
||||
@@ -17,7 +17,7 @@ from homeassistant.const import (
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['python-eq3bt==0.1.5']
|
||||
REQUIREMENTS = ['python-eq3bt==0.1.6']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,4 +164,8 @@ class EQ3BTSmartThermostat(ClimateDevice):
|
||||
|
||||
def update(self):
|
||||
"""Update the data from the thermostat."""
|
||||
self._thermostat.update()
|
||||
from bluepy.btle import BTLEException
|
||||
try:
|
||||
self._thermostat.update()
|
||||
except BTLEException as ex:
|
||||
_LOGGER.warning("Updating the state failed: %s", ex)
|
||||
|
||||
@@ -36,7 +36,8 @@ CONF_MAX_TEMP = 'max_temp'
|
||||
CONF_TARGET_TEMP = 'target_temp'
|
||||
CONF_AC_MODE = 'ac_mode'
|
||||
CONF_MIN_DUR = 'min_cycle_duration'
|
||||
CONF_TOLERANCE = 'tolerance'
|
||||
CONF_COLD_TOLERANCE = 'cold_tolerance'
|
||||
CONF_HOT_TOLERANCE = 'hot_tolerance'
|
||||
CONF_KEEP_ALIVE = 'keep_alive'
|
||||
|
||||
|
||||
@@ -48,7 +49,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
|
||||
vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(
|
||||
float),
|
||||
vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(
|
||||
float),
|
||||
vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_KEEP_ALIVE): vol.All(
|
||||
cv.time_period, cv.positive_timedelta),
|
||||
@@ -66,12 +70,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
target_temp = config.get(CONF_TARGET_TEMP)
|
||||
ac_mode = config.get(CONF_AC_MODE)
|
||||
min_cycle_duration = config.get(CONF_MIN_DUR)
|
||||
tolerance = config.get(CONF_TOLERANCE)
|
||||
cold_tolerance = config.get(CONF_COLD_TOLERANCE)
|
||||
hot_tolerance = config.get(CONF_HOT_TOLERANCE)
|
||||
keep_alive = config.get(CONF_KEEP_ALIVE)
|
||||
|
||||
async_add_devices([GenericThermostat(
|
||||
hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp,
|
||||
target_temp, ac_mode, min_cycle_duration, tolerance, keep_alive)])
|
||||
target_temp, ac_mode, min_cycle_duration, cold_tolerance,
|
||||
hot_tolerance, keep_alive)])
|
||||
|
||||
|
||||
class GenericThermostat(ClimateDevice):
|
||||
@@ -79,14 +85,15 @@ class GenericThermostat(ClimateDevice):
|
||||
|
||||
def __init__(self, hass, name, heater_entity_id, sensor_entity_id,
|
||||
min_temp, max_temp, target_temp, ac_mode, min_cycle_duration,
|
||||
tolerance, keep_alive):
|
||||
cold_tolerance, hot_tolerance, keep_alive):
|
||||
"""Initialize the thermostat."""
|
||||
self.hass = hass
|
||||
self._name = name
|
||||
self.heater_entity_id = heater_entity_id
|
||||
self.ac_mode = ac_mode
|
||||
self.min_cycle_duration = min_cycle_duration
|
||||
self._tolerance = tolerance
|
||||
self._cold_tolerance = cold_tolerance
|
||||
self._hot_tolerance = hot_tolerance
|
||||
self._keep_alive = keep_alive
|
||||
self._enabled = True
|
||||
|
||||
@@ -261,25 +268,29 @@ class GenericThermostat(ClimateDevice):
|
||||
if self.ac_mode:
|
||||
is_cooling = self._is_device_active
|
||||
if is_cooling:
|
||||
too_cold = self._target_temp - self._cur_temp > self._tolerance
|
||||
too_cold = self._target_temp - self._cur_temp >= \
|
||||
self._cold_tolerance
|
||||
if too_cold:
|
||||
_LOGGER.info('Turning off AC %s', self.heater_entity_id)
|
||||
switch.async_turn_off(self.hass, self.heater_entity_id)
|
||||
else:
|
||||
too_hot = self._cur_temp - self._target_temp > self._tolerance
|
||||
too_hot = self._cur_temp - self._target_temp >= \
|
||||
self._hot_tolerance
|
||||
if too_hot:
|
||||
_LOGGER.info('Turning on AC %s', self.heater_entity_id)
|
||||
switch.async_turn_on(self.hass, self.heater_entity_id)
|
||||
else:
|
||||
is_heating = self._is_device_active
|
||||
if is_heating:
|
||||
too_hot = self._cur_temp - self._target_temp > self._tolerance
|
||||
too_hot = self._cur_temp - self._target_temp >= \
|
||||
self._hot_tolerance
|
||||
if too_hot:
|
||||
_LOGGER.info('Turning off heater %s',
|
||||
self.heater_entity_id)
|
||||
switch.async_turn_off(self.hass, self.heater_entity_id)
|
||||
else:
|
||||
too_cold = self._target_temp - self._cur_temp > self._tolerance
|
||||
too_cold = self._target_temp - self._cur_temp >= \
|
||||
self._cold_tolerance
|
||||
if too_cold:
|
||||
_LOGGER.info('Turning on heater %s', self.heater_entity_id)
|
||||
switch.async_turn_on(self.hass, self.heater_entity_id)
|
||||
|
||||
@@ -11,16 +11,15 @@ import datetime
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice, PLATFORM_SCHEMA, ATTR_FAN_MODE, ATTR_FAN_LIST,
|
||||
ATTR_OPERATION_MODE, ATTR_OPERATION_LIST)
|
||||
from homeassistant.const import (
|
||||
CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
|
||||
ATTR_TEMPERATURE)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
ATTR_TEMPERATURE, CONF_REGION)
|
||||
|
||||
REQUIREMENTS = ['evohomeclient==0.2.5',
|
||||
'somecomfort==0.4.1']
|
||||
REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.4.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +30,6 @@ ATTR_CURRENT_OPERATION = 'equipment_output_status'
|
||||
CONF_AWAY_TEMPERATURE = 'away_temperature'
|
||||
CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature'
|
||||
CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature'
|
||||
CONF_REGION = 'region'
|
||||
|
||||
DEFAULT_AWAY_TEMPERATURE = 16
|
||||
DEFAULT_COOL_AWAY_TEMPERATURE = 30
|
||||
|
||||
@@ -14,6 +14,8 @@ from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
CONF_SETPOINT_ADDRESS = 'setpoint_address'
|
||||
CONF_SETPOINT_SHIFT_ADDRESS = 'setpoint_shift_address'
|
||||
CONF_SETPOINT_SHIFT_STATE_ADDRESS = 'setpoint_shift_state_address'
|
||||
CONF_TEMPERATURE_ADDRESS = 'temperature_address'
|
||||
CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address'
|
||||
CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address'
|
||||
@@ -33,6 +35,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_SETPOINT_ADDRESS): cv.string,
|
||||
vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string,
|
||||
vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string,
|
||||
@@ -82,6 +86,10 @@ def async_add_devices_config(hass, config, async_add_devices):
|
||||
CONF_TARGET_TEMPERATURE_ADDRESS),
|
||||
group_address_setpoint=config.get(
|
||||
CONF_SETPOINT_ADDRESS),
|
||||
group_address_setpoint_shift=config.get(
|
||||
CONF_SETPOINT_SHIFT_ADDRESS),
|
||||
group_address_setpoint_shift_state=config.get(
|
||||
CONF_SETPOINT_SHIFT_STATE_ADDRESS),
|
||||
group_address_operation_mode=config.get(
|
||||
CONF_OPERATION_MODE_ADDRESS),
|
||||
group_address_operation_mode_state=config.get(
|
||||
@@ -140,13 +148,29 @@ class KNXClimate(ClimateDevice):
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self.device.temperature
|
||||
return self.device.temperature.value
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
if self.device.supports_target_temperature:
|
||||
return self.device.target_temperature
|
||||
return self.device.target_temperature_comfort
|
||||
|
||||
@property
|
||||
def target_temperature_high(self):
|
||||
"""Return the highbound target temperature we try to reach."""
|
||||
if self.device.target_temperature_comfort:
|
||||
return max(
|
||||
self.device.target_temperature_comfort,
|
||||
self.device.target_temperature.value)
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_low(self):
|
||||
"""Return the lowbound target temperature we try to reach."""
|
||||
if self.device.target_temperature_comfort:
|
||||
return min(
|
||||
self.device.target_temperature_comfort,
|
||||
self.device.target_temperature.value)
|
||||
return None
|
||||
|
||||
@asyncio.coroutine
|
||||
@@ -155,8 +179,8 @@ class KNXClimate(ClimateDevice):
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temperature is None:
|
||||
return
|
||||
if self.device.supports_target_temperature:
|
||||
yield from self.device.set_target_temperature(temperature)
|
||||
yield from self.device.set_target_temperature_comfort(temperature)
|
||||
yield from self.async_update_ha_state()
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
|
||||
@@ -18,7 +18,8 @@ from homeassistant.components.climate import (
|
||||
ATTR_OPERATION_MODE)
|
||||
from homeassistant.const import (
|
||||
STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME)
|
||||
from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN)
|
||||
from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN,
|
||||
MQTT_BASE_PLATFORM_SCHEMA)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM,
|
||||
SPEED_HIGH)
|
||||
@@ -57,7 +58,8 @@ CONF_SWING_MODE_LIST = 'swing_modes'
|
||||
CONF_INITIAL = 'initial'
|
||||
CONF_SEND_IF_OFF = 'send_if_off'
|
||||
|
||||
PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({
|
||||
SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema)
|
||||
PLATFORM_SCHEMA = SCHEMA_BASE.extend({
|
||||
vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_TEMPERATURE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
|
||||
@@ -1,132 +1,102 @@
|
||||
# Describes the format for available climate services
|
||||
|
||||
set_aux_heat:
|
||||
description: Turn auxiliary heater on/off for climate device
|
||||
|
||||
description: Turn auxiliary heater on/off for climate device.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.kitchen'
|
||||
|
||||
aux_heat:
|
||||
description: New value of axillary heater
|
||||
description: New value of axillary heater.
|
||||
example: true
|
||||
|
||||
set_away_mode:
|
||||
description: Turn away mode on/off for climate device
|
||||
|
||||
description: Turn away mode on/off for climate device.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.kitchen'
|
||||
|
||||
away_mode:
|
||||
description: New value of away mode
|
||||
description: New value of away mode.
|
||||
example: true
|
||||
|
||||
set_hold_mode:
|
||||
description: Turn hold mode for climate device
|
||||
|
||||
description: Turn hold mode for climate device.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.kitchen'
|
||||
|
||||
hold_mode:
|
||||
description: New value of hold mode
|
||||
example: 'away'
|
||||
|
||||
set_temperature:
|
||||
description: Set target temperature of climate device
|
||||
|
||||
description: Set target temperature of climate device.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.kitchen'
|
||||
|
||||
temperature:
|
||||
description: New target temperature for hvac
|
||||
description: New target temperature for HVAC.
|
||||
example: 25
|
||||
|
||||
target_temp_high:
|
||||
description: New target high tempereature for hvac
|
||||
description: New target high tempereature for HVAC.
|
||||
example: 26
|
||||
|
||||
target_temp_low:
|
||||
description: New target low temperature for hvac
|
||||
description: New target low temperature for HVAC.
|
||||
example: 20
|
||||
|
||||
operation_mode:
|
||||
description: Operation mode to set temperature to. This defaults to current_operation mode if not set, or set incorrectly.
|
||||
example: 'Heat'
|
||||
|
||||
set_humidity:
|
||||
description: Set target humidity of climate device
|
||||
|
||||
description: Set target humidity of climate device.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.kitchen'
|
||||
|
||||
humidity:
|
||||
description: New target humidity for climate device
|
||||
description: New target humidity for climate device.
|
||||
example: 60
|
||||
|
||||
set_fan_mode:
|
||||
description: Set fan operation for climate device
|
||||
|
||||
description: Set fan operation for climate device.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.nest'
|
||||
|
||||
fan_mode:
|
||||
description: New value of fan mode
|
||||
description: New value of fan mode.
|
||||
example: On Low
|
||||
|
||||
set_operation_mode:
|
||||
description: Set operation mode for climate device
|
||||
|
||||
description: Set operation mode for climate device.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.nest'
|
||||
|
||||
operation_mode:
|
||||
description: New value of operation mode
|
||||
description: New value of operation mode.
|
||||
example: Heat
|
||||
|
||||
|
||||
set_swing_mode:
|
||||
description: Set swing operation for climate device
|
||||
|
||||
description: Set swing operation for climate device.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
example: '.nest'
|
||||
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.nest'
|
||||
swing_mode:
|
||||
description: New value of swing mode
|
||||
example: 1
|
||||
|
||||
description: New value of swing mode.
|
||||
example: 1
|
||||
ecobee_set_fan_min_on_time:
|
||||
description: Set the minimum fan on time
|
||||
|
||||
description: Set the minimum fan on time.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.kitchen'
|
||||
|
||||
fan_min_on_time:
|
||||
description: New value of fan min on time
|
||||
description: New value of fan min on time.
|
||||
example: 5
|
||||
|
||||
ecobee_resume_program:
|
||||
description: Resume the programmed schedule
|
||||
|
||||
description: Resume the programmed schedule.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.kitchen'
|
||||
|
||||
resume_all:
|
||||
description: Resume all events and return to the scheduled program. This default to false which removes only the top event.
|
||||
example: true
|
||||
|
||||
@@ -35,7 +35,6 @@ class TeslaThermostat(TeslaDevice, ClimateDevice):
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id)
|
||||
self._target_temperature = None
|
||||
self._temperature = None
|
||||
self._name = self.tesla_device.name
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
|
||||
90
homeassistant/components/climate/toon.py
Normal file
90
homeassistant/components/climate/toon.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Toon van Eneco Thermostat Support.
|
||||
|
||||
This provides a component for the rebranded Quby thermostat as provided by
|
||||
Eneco.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.toon/
|
||||
"""
|
||||
import homeassistant.components.toon as toon_main
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice, ATTR_TEMPERATURE, STATE_PERFORMANCE, STATE_HEAT, STATE_ECO,
|
||||
STATE_COOL)
|
||||
from homeassistant.const import TEMP_CELSIUS
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Toon thermostat."""
|
||||
add_devices([ThermostatDevice(hass)], True)
|
||||
|
||||
|
||||
class ThermostatDevice(ClimateDevice):
|
||||
"""Interface class for the toon module and HA."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize the device."""
|
||||
self._name = 'Toon van Eneco'
|
||||
self.hass = hass
|
||||
self.thermos = hass.data[toon_main.TOON_HANDLE]
|
||||
|
||||
self._state = None
|
||||
self._temperature = None
|
||||
self._setpoint = None
|
||||
self._operation_list = [
|
||||
STATE_PERFORMANCE,
|
||||
STATE_HEAT,
|
||||
STATE_ECO,
|
||||
STATE_COOL,
|
||||
]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Name of this Thermostat."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""The unit of measurement used by the platform."""
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation i.e. comfort, home, away."""
|
||||
state = self.thermos.get_data('state')
|
||||
return state
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""List of available operation modes."""
|
||||
return self._operation_list
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self.thermos.get_data('temp')
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self.thermos.get_data('setpoint')
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Change the setpoint of the thermostat."""
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
self.thermos.set_temp(temp)
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set new operation mode as toonlib requires it."""
|
||||
toonlib_values = {
|
||||
STATE_PERFORMANCE: 'Comfort',
|
||||
STATE_HEAT: 'Home',
|
||||
STATE_ECO: 'Away',
|
||||
STATE_COOL: 'Sleep',
|
||||
}
|
||||
|
||||
self.thermos.set_state(toonlib_values[operation_mode])
|
||||
|
||||
def update(self):
|
||||
"""Update local state."""
|
||||
self.thermos.update()
|
||||
@@ -1,47 +1,148 @@
|
||||
"""Component to integrate the Home Assistant cloud."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from . import http_api, auth_api
|
||||
from .const import DOMAIN
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE)
|
||||
|
||||
from . import http_api, iot
|
||||
from .const import CONFIG_DIR, DOMAIN, SERVERS
|
||||
|
||||
REQUIREMENTS = ['warrant==0.5.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
|
||||
CONF_RELAYER = 'relayer'
|
||||
CONF_USER_POOL_ID = 'user_pool_id'
|
||||
|
||||
REQUIREMENTS = ['warrant==0.2.0']
|
||||
DEPENDENCIES = ['http']
|
||||
CONF_MODE = 'mode'
|
||||
MODE_DEV = 'development'
|
||||
MODE_STAGING = 'staging'
|
||||
MODE_PRODUCTION = 'production'
|
||||
DEFAULT_MODE = MODE_DEV
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_MODE, default=DEFAULT_MODE):
|
||||
vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]),
|
||||
vol.In([MODE_DEV] + list(SERVERS)),
|
||||
# Change to optional when we include real servers
|
||||
vol.Required(CONF_COGNITO_CLIENT_ID): str,
|
||||
vol.Required(CONF_USER_POOL_ID): str,
|
||||
vol.Required(CONF_REGION): str,
|
||||
vol.Required(CONF_RELAYER): str,
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Initialize the Home Assistant cloud."""
|
||||
mode = MODE_PRODUCTION
|
||||
|
||||
if DOMAIN in config:
|
||||
mode = config[DOMAIN].get(CONF_MODE)
|
||||
kwargs = config[DOMAIN]
|
||||
else:
|
||||
kwargs = {CONF_MODE: DEFAULT_MODE}
|
||||
|
||||
if mode != 'development':
|
||||
_LOGGER.error('Only development mode is currently allowed.')
|
||||
return False
|
||||
cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs)
|
||||
|
||||
data = hass.data[DOMAIN] = {
|
||||
'mode': mode
|
||||
}
|
||||
@asyncio.coroutine
|
||||
def init_cloud(event):
|
||||
"""Initialize connection."""
|
||||
yield from cloud.initialize()
|
||||
|
||||
data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, init_cloud)
|
||||
|
||||
yield from http_api.async_setup(hass)
|
||||
return True
|
||||
|
||||
|
||||
class Cloud:
|
||||
"""Store the configuration of the cloud connection."""
|
||||
|
||||
def __init__(self, hass, mode, cognito_client_id=None, user_pool_id=None,
|
||||
region=None, relayer=None):
|
||||
"""Create an instance of Cloud."""
|
||||
self.hass = hass
|
||||
self.mode = mode
|
||||
self.email = None
|
||||
self.id_token = None
|
||||
self.access_token = None
|
||||
self.refresh_token = None
|
||||
self.iot = iot.CloudIoT(self)
|
||||
|
||||
if mode == MODE_DEV:
|
||||
self.cognito_client_id = cognito_client_id
|
||||
self.user_pool_id = user_pool_id
|
||||
self.region = region
|
||||
self.relayer = relayer
|
||||
|
||||
else:
|
||||
info = SERVERS[mode]
|
||||
|
||||
self.cognito_client_id = info['cognito_client_id']
|
||||
self.user_pool_id = info['user_pool_id']
|
||||
self.region = info['region']
|
||||
self.relayer = info['relayer']
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
"""Get if cloud is logged in."""
|
||||
return self.email is not None
|
||||
|
||||
@property
|
||||
def user_info_path(self):
|
||||
"""Get path to the stored auth."""
|
||||
return self.path('{}_auth.json'.format(self.mode))
|
||||
|
||||
@asyncio.coroutine
|
||||
def initialize(self):
|
||||
"""Initialize and load cloud info."""
|
||||
def load_config():
|
||||
"""Load the configuration."""
|
||||
# Ensure config dir exists
|
||||
path = self.hass.config.path(CONFIG_DIR)
|
||||
if not os.path.isdir(path):
|
||||
os.mkdir(path)
|
||||
|
||||
user_info = self.user_info_path
|
||||
if os.path.isfile(user_info):
|
||||
with open(user_info, 'rt') as file:
|
||||
info = json.loads(file.read())
|
||||
self.email = info['email']
|
||||
self.id_token = info['id_token']
|
||||
self.access_token = info['access_token']
|
||||
self.refresh_token = info['refresh_token']
|
||||
|
||||
yield from self.hass.async_add_job(load_config)
|
||||
|
||||
if self.email is not None:
|
||||
yield from self.iot.connect()
|
||||
|
||||
def path(self, *parts):
|
||||
"""Get config path inside cloud dir."""
|
||||
return self.hass.config.path(CONFIG_DIR, *parts)
|
||||
|
||||
@asyncio.coroutine
|
||||
def logout(self):
|
||||
"""Close connection and remove all credentials."""
|
||||
yield from self.iot.disconnect()
|
||||
|
||||
self.email = None
|
||||
self.id_token = None
|
||||
self.access_token = None
|
||||
self.refresh_token = None
|
||||
|
||||
yield from self.hass.async_add_job(
|
||||
lambda: os.remove(self.user_info_path))
|
||||
|
||||
def write_user_info(self):
|
||||
"""Write user info to a file."""
|
||||
with open(self.user_info_path, 'wt') as file:
|
||||
file.write(json.dumps({
|
||||
'email': self.email,
|
||||
'id_token': self.id_token,
|
||||
'access_token': self.access_token,
|
||||
'refresh_token': self.refresh_token,
|
||||
}, indent=4))
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Package to offer tools to authenticate with the cloud."""
|
||||
import json
|
||||
"""Package to communicate with the authentication API."""
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .const import AUTH_FILE, SERVERS
|
||||
from .util import get_mode
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,210 +58,120 @@ def _map_aws_exception(err):
|
||||
return ex(err.response['Error']['Message'])
|
||||
|
||||
|
||||
def load_auth(hass):
|
||||
"""Load authentication from disk and verify it."""
|
||||
info = _read_info(hass)
|
||||
|
||||
if info is None:
|
||||
return Auth(hass)
|
||||
|
||||
auth = Auth(hass, _cognito(
|
||||
hass,
|
||||
id_token=info['id_token'],
|
||||
access_token=info['access_token'],
|
||||
refresh_token=info['refresh_token'],
|
||||
))
|
||||
|
||||
if auth.validate_auth():
|
||||
return auth
|
||||
|
||||
return Auth(hass)
|
||||
def _generate_username(email):
|
||||
"""Generate a username from an email address."""
|
||||
return hashlib.sha512(email.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def register(hass, email, password):
|
||||
def register(cloud, email, password):
|
||||
"""Register a new account."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
cognito = _cognito(cloud)
|
||||
try:
|
||||
cognito.register(email, password)
|
||||
cognito.register(_generate_username(email), password, email=email)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def confirm_register(hass, confirmation_code, email):
|
||||
def confirm_register(cloud, confirmation_code, email):
|
||||
"""Confirm confirmation code after registration."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
cognito = _cognito(cloud)
|
||||
try:
|
||||
cognito.confirm_sign_up(confirmation_code, email)
|
||||
cognito.confirm_sign_up(confirmation_code, _generate_username(email))
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def forgot_password(hass, email):
|
||||
def forgot_password(cloud, email):
|
||||
"""Initiate forgotten password flow."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
cognito = _cognito(cloud, username=_generate_username(email))
|
||||
try:
|
||||
cognito.initiate_forgot_password()
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def confirm_forgot_password(hass, confirmation_code, email, new_password):
|
||||
def confirm_forgot_password(cloud, confirmation_code, email, new_password):
|
||||
"""Confirm forgotten password code and change password."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
cognito = _cognito(cloud, username=_generate_username(email))
|
||||
try:
|
||||
cognito.confirm_forgot_password(confirmation_code, new_password)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
class Auth(object):
|
||||
"""Class that holds Cloud authentication."""
|
||||
|
||||
def __init__(self, hass, cognito=None):
|
||||
"""Initialize Hass cloud info object."""
|
||||
self.hass = hass
|
||||
self.cognito = cognito
|
||||
self.account = None
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
"""Return if user is logged in."""
|
||||
return self.account is not None
|
||||
|
||||
def validate_auth(self):
|
||||
"""Validate that the contained auth is valid."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self._refresh_account_info()
|
||||
except ClientError as err:
|
||||
if err.response['Error']['Code'] != 'NotAuthorizedException':
|
||||
_LOGGER.error('Unexpected error verifying auth: %s', err)
|
||||
return False
|
||||
|
||||
try:
|
||||
self.renew_access_token()
|
||||
self._refresh_account_info()
|
||||
except ClientError:
|
||||
_LOGGER.error('Unable to refresh auth token: %s', err)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def login(self, username, password):
|
||||
"""Login using a username and password."""
|
||||
from botocore.exceptions import ClientError
|
||||
from warrant.exceptions import ForceChangePasswordException
|
||||
|
||||
cognito = _cognito(self.hass, username=username)
|
||||
|
||||
try:
|
||||
cognito.authenticate(password=password)
|
||||
self.cognito = cognito
|
||||
self._refresh_account_info()
|
||||
_write_info(self.hass, self)
|
||||
|
||||
except ForceChangePasswordException as err:
|
||||
raise PasswordChangeRequired
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
def _refresh_account_info(self):
|
||||
"""Refresh the account info.
|
||||
|
||||
Raises boto3 exceptions.
|
||||
"""
|
||||
self.account = self.cognito.get_user()
|
||||
|
||||
def renew_access_token(self):
|
||||
"""Refresh token."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self.cognito.renew_access_token()
|
||||
_write_info(self.hass, self)
|
||||
return True
|
||||
except ClientError as err:
|
||||
_LOGGER.error('Error refreshing token: %s', err)
|
||||
return False
|
||||
|
||||
def logout(self):
|
||||
"""Invalidate token."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self.cognito.logout()
|
||||
self.account = None
|
||||
_write_info(self.hass, self)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
def login(cloud, email, password):
|
||||
"""Log user in and fetch certificate."""
|
||||
cognito = _authenticate(cloud, email, password)
|
||||
cloud.id_token = cognito.id_token
|
||||
cloud.access_token = cognito.access_token
|
||||
cloud.refresh_token = cognito.refresh_token
|
||||
cloud.email = email
|
||||
cloud.write_user_info()
|
||||
|
||||
|
||||
def _read_info(hass):
|
||||
"""Read auth file."""
|
||||
path = hass.config.path(AUTH_FILE)
|
||||
def check_token(cloud):
|
||||
"""Check that the token is valid and verify if needed."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
cognito = _cognito(
|
||||
cloud,
|
||||
access_token=cloud.access_token,
|
||||
refresh_token=cloud.refresh_token)
|
||||
|
||||
with open(path) as file:
|
||||
return json.load(file).get(get_mode(hass))
|
||||
try:
|
||||
if cognito.check_token():
|
||||
cloud.id_token = cognito.id_token
|
||||
cloud.access_token = cognito.access_token
|
||||
cloud.write_user_info()
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def _write_info(hass, auth):
|
||||
"""Write auth info for specified mode.
|
||||
def _authenticate(cloud, email, password):
|
||||
"""Log in and return an authenticated Cognito instance."""
|
||||
from botocore.exceptions import ClientError
|
||||
from warrant.exceptions import ForceChangePasswordException
|
||||
|
||||
Pass in None for data to remove authentication for that mode.
|
||||
"""
|
||||
path = hass.config.path(AUTH_FILE)
|
||||
mode = get_mode(hass)
|
||||
assert not cloud.is_logged_in, 'Cannot login if already logged in.'
|
||||
|
||||
if os.path.isfile(path):
|
||||
with open(path) as file:
|
||||
content = json.load(file)
|
||||
else:
|
||||
content = {}
|
||||
cognito = _cognito(cloud, username=email)
|
||||
|
||||
if auth.is_logged_in:
|
||||
content[mode] = {
|
||||
'id_token': auth.cognito.id_token,
|
||||
'access_token': auth.cognito.access_token,
|
||||
'refresh_token': auth.cognito.refresh_token,
|
||||
}
|
||||
else:
|
||||
content.pop(mode, None)
|
||||
try:
|
||||
cognito.authenticate(password=password)
|
||||
return cognito
|
||||
|
||||
with open(path, 'wt') as file:
|
||||
file.write(json.dumps(content, indent=4, sort_keys=True))
|
||||
except ForceChangePasswordException as err:
|
||||
raise PasswordChangeRequired
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def _cognito(hass, **kwargs):
|
||||
def _cognito(cloud, **kwargs):
|
||||
"""Get the client credentials."""
|
||||
import botocore
|
||||
import boto3
|
||||
from warrant import Cognito
|
||||
|
||||
mode = get_mode(hass)
|
||||
|
||||
info = SERVERS.get(mode)
|
||||
|
||||
if info is None:
|
||||
raise ValueError('Mode {} is not supported.'.format(mode))
|
||||
|
||||
cognito = Cognito(
|
||||
user_pool_id=info['identity_pool_id'],
|
||||
client_id=info['client_id'],
|
||||
user_pool_region=info['region'],
|
||||
access_key=info['access_key_id'],
|
||||
secret_key=info['secret_access_key'],
|
||||
user_pool_id=cloud.user_pool_id,
|
||||
client_id=cloud.cognito_client_id,
|
||||
user_pool_region=cloud.region,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
cognito.client = boto3.client(
|
||||
'cognito-idp',
|
||||
region_name=cloud.region,
|
||||
config=botocore.config.Config(
|
||||
signature_version=botocore.UNSIGNED
|
||||
)
|
||||
)
|
||||
return cognito
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Constants for the cloud component."""
|
||||
DOMAIN = 'cloud'
|
||||
CONFIG_DIR = '.cloud'
|
||||
REQUEST_TIMEOUT = 10
|
||||
AUTH_FILE = '.cloud'
|
||||
|
||||
SERVERS = {
|
||||
'development': {
|
||||
'client_id': '3k755iqfcgv8t12o4pl662mnos',
|
||||
'identity_pool_id': 'us-west-2_vDOfweDJo',
|
||||
'region': 'us-west-2',
|
||||
'access_key_id': 'AKIAJGRK7MILPRJTT2ZQ',
|
||||
'secret_access_key': 'lscdYBApxrLWL0HKuVqVXWv3ou8ZVXgG7rZBu/Sz'
|
||||
}
|
||||
# Example entry:
|
||||
# 'production': {
|
||||
# 'cognito_client_id': '',
|
||||
# 'user_pool_id': '',
|
||||
# 'region': '',
|
||||
# 'relayer': ''
|
||||
# }
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.components.http import (
|
||||
HomeAssistantView, RequestDataValidator)
|
||||
|
||||
from . import auth_api
|
||||
from .const import REQUEST_TIMEOUT
|
||||
from .const import DOMAIN, REQUEST_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,13 +74,14 @@ class CloudLoginView(HomeAssistantView):
|
||||
def post(self, request, data):
|
||||
"""Handle login request."""
|
||||
hass = request.app['hass']
|
||||
auth = hass.data['cloud']['auth']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(auth.login, data['email'],
|
||||
yield from hass.async_add_job(auth_api.login, cloud, data['email'],
|
||||
data['password'])
|
||||
hass.async_add_job(cloud.iot.connect)
|
||||
|
||||
return self.json(_auth_data(auth))
|
||||
return self.json(_account_data(cloud))
|
||||
|
||||
|
||||
class CloudLogoutView(HomeAssistantView):
|
||||
@@ -94,10 +95,10 @@ class CloudLogoutView(HomeAssistantView):
|
||||
def post(self, request):
|
||||
"""Handle logout request."""
|
||||
hass = request.app['hass']
|
||||
auth = hass.data['cloud']['auth']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(auth.logout)
|
||||
yield from cloud.logout()
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
@@ -112,12 +113,12 @@ class CloudAccountView(HomeAssistantView):
|
||||
def get(self, request):
|
||||
"""Get account info."""
|
||||
hass = request.app['hass']
|
||||
auth = hass.data['cloud']['auth']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
if not auth.is_logged_in:
|
||||
if not cloud.is_logged_in:
|
||||
return self.json_message('Not logged in', 400)
|
||||
|
||||
return self.json(_auth_data(auth))
|
||||
return self.json(_account_data(cloud))
|
||||
|
||||
|
||||
class CloudRegisterView(HomeAssistantView):
|
||||
@@ -135,10 +136,11 @@ class CloudRegisterView(HomeAssistantView):
|
||||
def post(self, request, data):
|
||||
"""Handle registration request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.register, hass, data['email'], data['password'])
|
||||
auth_api.register, cloud, data['email'], data['password'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
@@ -158,10 +160,11 @@ class CloudConfirmRegisterView(HomeAssistantView):
|
||||
def post(self, request, data):
|
||||
"""Handle registration confirmation request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.confirm_register, hass, data['confirmation_code'],
|
||||
auth_api.confirm_register, cloud, data['confirmation_code'],
|
||||
data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
@@ -181,10 +184,11 @@ class CloudForgotPasswordView(HomeAssistantView):
|
||||
def post(self, request, data):
|
||||
"""Handle forgot password request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.forgot_password, hass, data['email'])
|
||||
auth_api.forgot_password, cloud, data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
@@ -205,18 +209,19 @@ class CloudConfirmForgotPasswordView(HomeAssistantView):
|
||||
def post(self, request, data):
|
||||
"""Handle forgot password confirm request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.confirm_forgot_password, hass,
|
||||
auth_api.confirm_forgot_password, cloud,
|
||||
data['confirmation_code'], data['email'],
|
||||
data['new_password'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
def _auth_data(auth):
|
||||
def _account_data(cloud):
|
||||
"""Generate the auth data JSON response."""
|
||||
return {
|
||||
'email': auth.account.email
|
||||
'email': cloud.email
|
||||
}
|
||||
|
||||
194
homeassistant/components/cloud/iot.py
Normal file
194
homeassistant/components/cloud/iot.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Module to handle messages from Home Assistant cloud."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import hdrs, client_exceptions, WSMsgType
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.components.alexa import smart_home
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from . import auth_api
|
||||
|
||||
|
||||
HANDLERS = Registry()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnknownHandler(Exception):
|
||||
"""Exception raised when trying to handle unknown handler."""
|
||||
|
||||
|
||||
class CloudIoT:
|
||||
"""Class to manage the IoT connection."""
|
||||
|
||||
def __init__(self, cloud):
|
||||
"""Initialize the CloudIoT class."""
|
||||
self.cloud = cloud
|
||||
self.client = None
|
||||
self.close_requested = False
|
||||
self.tries = 0
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
"""Return if connected to the cloud."""
|
||||
return self.client is not None
|
||||
|
||||
@asyncio.coroutine
|
||||
def connect(self):
|
||||
"""Connect to the IoT broker."""
|
||||
if self.client is not None:
|
||||
raise RuntimeError('Cannot connect while already connected')
|
||||
|
||||
self.close_requested = False
|
||||
|
||||
hass = self.cloud.hass
|
||||
remove_hass_stop_listener = None
|
||||
|
||||
session = async_get_clientsession(self.cloud.hass)
|
||||
|
||||
@asyncio.coroutine
|
||||
def _handle_hass_stop(event):
|
||||
"""Handle Home Assistant shutting down."""
|
||||
nonlocal remove_hass_stop_listener
|
||||
remove_hass_stop_listener = None
|
||||
yield from self.disconnect()
|
||||
|
||||
client = None
|
||||
disconnect_warn = None
|
||||
try:
|
||||
yield from hass.async_add_job(auth_api.check_token, self.cloud)
|
||||
|
||||
self.client = client = yield from session.ws_connect(
|
||||
self.cloud.relayer, headers={
|
||||
hdrs.AUTHORIZATION:
|
||||
'Bearer {}'.format(self.cloud.id_token)
|
||||
})
|
||||
self.tries = 0
|
||||
|
||||
remove_hass_stop_listener = hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, _handle_hass_stop)
|
||||
|
||||
_LOGGER.info('Connected')
|
||||
|
||||
while not client.closed:
|
||||
msg = yield from client.receive()
|
||||
|
||||
if msg.type in (WSMsgType.ERROR, WSMsgType.CLOSED,
|
||||
WSMsgType.CLOSING):
|
||||
disconnect_warn = 'Closed by server'
|
||||
break
|
||||
|
||||
elif msg.type != WSMsgType.TEXT:
|
||||
disconnect_warn = 'Received non-Text message: {}'.format(
|
||||
msg.type)
|
||||
break
|
||||
|
||||
try:
|
||||
msg = msg.json()
|
||||
except ValueError:
|
||||
disconnect_warn = 'Received invalid JSON.'
|
||||
break
|
||||
|
||||
_LOGGER.debug('Received message: %s', msg)
|
||||
|
||||
response = {
|
||||
'msgid': msg['msgid'],
|
||||
}
|
||||
try:
|
||||
result = yield from async_handle_message(
|
||||
hass, self.cloud, msg['handler'], msg['payload'])
|
||||
|
||||
# No response from handler
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
response['payload'] = result
|
||||
|
||||
except UnknownHandler:
|
||||
response['error'] = 'unknown-handler'
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception('Error handling message')
|
||||
response['error'] = 'exception'
|
||||
|
||||
_LOGGER.debug('Publishing message: %s', response)
|
||||
yield from client.send_json(response)
|
||||
|
||||
except auth_api.CloudError:
|
||||
_LOGGER.warning('Unable to connect: Unable to refresh token.')
|
||||
|
||||
except client_exceptions.WSServerHandshakeError as err:
|
||||
if err.code == 401:
|
||||
disconnect_warn = 'Invalid auth.'
|
||||
self.close_requested = True
|
||||
# Should we notify user?
|
||||
else:
|
||||
_LOGGER.warning('Unable to connect: %s', err)
|
||||
|
||||
except client_exceptions.ClientError as err:
|
||||
_LOGGER.warning('Unable to connect: %s', err)
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
if not self.close_requested:
|
||||
_LOGGER.exception('Unexpected error')
|
||||
|
||||
finally:
|
||||
if disconnect_warn is not None:
|
||||
_LOGGER.warning('Connection closed: %s', disconnect_warn)
|
||||
|
||||
if remove_hass_stop_listener is not None:
|
||||
remove_hass_stop_listener()
|
||||
|
||||
if client is not None:
|
||||
self.client = None
|
||||
yield from client.close()
|
||||
|
||||
if not self.close_requested:
|
||||
self.tries += 1
|
||||
|
||||
# Sleep 0, 5, 10, 15 … up to 30 seconds between retries
|
||||
yield from asyncio.sleep(
|
||||
min(30, (self.tries - 1) * 5), loop=hass.loop)
|
||||
|
||||
hass.async_add_job(self.connect())
|
||||
|
||||
@asyncio.coroutine
|
||||
def disconnect(self):
|
||||
"""Disconnect the client."""
|
||||
self.close_requested = True
|
||||
yield from self.client.close()
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, cloud, handler_name, payload):
|
||||
"""Handle incoming IoT message."""
|
||||
handler = HANDLERS.get(handler_name)
|
||||
|
||||
if handler is None:
|
||||
raise UnknownHandler()
|
||||
|
||||
return (yield from handler(hass, cloud, payload))
|
||||
|
||||
|
||||
@HANDLERS.register('alexa')
|
||||
@asyncio.coroutine
|
||||
def async_handle_alexa(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for Alexa."""
|
||||
return (yield from smart_home.async_handle_message(hass, payload))
|
||||
|
||||
|
||||
@HANDLERS.register('cloud')
|
||||
@asyncio.coroutine
|
||||
def async_handle_cloud(hass, cloud, payload):
|
||||
"""Handle an incoming IoT message for cloud component."""
|
||||
action = payload['action']
|
||||
|
||||
if action == 'logout':
|
||||
yield from cloud.logout()
|
||||
_LOGGER.error('You have been logged out from Home Assistant cloud: %s',
|
||||
payload['reason'])
|
||||
else:
|
||||
_LOGGER.warning('Received unknown cloud action: %s', action)
|
||||
|
||||
return None
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Utilities for the cloud integration."""
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
def get_mode(hass):
|
||||
"""Return the current mode of the cloud component.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data[DOMAIN]['mode']
|
||||
@@ -8,7 +8,6 @@ from homeassistant.core import callback
|
||||
from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID
|
||||
from homeassistant.setup import (
|
||||
async_prepare_setup_platform, ATTR_COMPONENT)
|
||||
from homeassistant.components.frontend import register_built_in_panel
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.util.yaml import load_yaml, dump
|
||||
|
||||
@@ -21,7 +20,8 @@ ON_DEMAND = ('zwave')
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Set up the config component."""
|
||||
register_built_in_panel(hass, 'config', 'Configuration', 'mdi:settings')
|
||||
yield from hass.components.frontend.async_register_built_in_panel(
|
||||
'config', 'config', 'mdi:settings')
|
||||
|
||||
@asyncio.coroutine
|
||||
def setup_panel(panel_name):
|
||||
|
||||
@@ -169,21 +169,12 @@ def async_setup(hass, config):
|
||||
params.pop(ATTR_ENTITY_ID, None)
|
||||
|
||||
# call method
|
||||
update_tasks = []
|
||||
for cover in covers:
|
||||
yield from getattr(cover, method['method'])(**params)
|
||||
|
||||
update_tasks = []
|
||||
|
||||
for cover in covers:
|
||||
if not cover.should_poll:
|
||||
continue
|
||||
|
||||
update_coro = hass.async_add_job(
|
||||
cover.async_update_ha_state(True))
|
||||
if hasattr(cover, 'async_update'):
|
||||
update_tasks.append(update_coro)
|
||||
else:
|
||||
yield from update_coro
|
||||
update_tasks.append(cover.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
@@ -1,71 +1,63 @@
|
||||
open_cover:
|
||||
description: Open all or specified cover
|
||||
# Describes the format for available cover services
|
||||
|
||||
open_cover:
|
||||
description: Open all or specified cover.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of cover(s) to open
|
||||
description: Name(s) of cover(s) to open.
|
||||
example: 'cover.living_room'
|
||||
|
||||
close_cover:
|
||||
description: Close all or specified cover
|
||||
|
||||
description: Close all or specified cover.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of cover(s) to close
|
||||
description: Name(s) of cover(s) to close.
|
||||
example: 'cover.living_room'
|
||||
|
||||
set_cover_position:
|
||||
description: Move to specific position all or specified cover
|
||||
|
||||
description: Move to specific position all or specified cover.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of cover(s) to set cover position
|
||||
description: Name(s) of cover(s) to set cover position.
|
||||
example: 'cover.living_room'
|
||||
|
||||
position:
|
||||
description: Position of the cover (0 to 100)
|
||||
description: Position of the cover (0 to 100).
|
||||
example: 30
|
||||
|
||||
stop_cover:
|
||||
description: Stop all or specified cover
|
||||
|
||||
description: Stop all or specified cover.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of cover(s) to stop
|
||||
description: Name(s) of cover(s) to stop.
|
||||
example: 'cover.living_room'
|
||||
|
||||
open_cover_tilt:
|
||||
description: Open all or specified cover tilt
|
||||
|
||||
description: Open all or specified cover tilt.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of cover(s) tilt to open
|
||||
description: Name(s) of cover(s) tilt to open.
|
||||
example: 'cover.living_room'
|
||||
|
||||
close_cover_tilt:
|
||||
description: Close all or specified cover tilt
|
||||
|
||||
description: Close all or specified cover tilt.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of cover(s) to close tilt
|
||||
description: Name(s) of cover(s) to close tilt.
|
||||
example: 'cover.living_room'
|
||||
|
||||
set_cover_tilt_position:
|
||||
description: Move to specific position all or specified cover tilt
|
||||
|
||||
description: Move to specific position all or specified cover tilt.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of cover(s) to set cover tilt position
|
||||
description: Name(s) of cover(s) to set cover tilt position.
|
||||
example: 'cover.living_room'
|
||||
|
||||
position:
|
||||
description: Position of the cover (0 to 100)
|
||||
description: Position of the cover (0 to 100).
|
||||
example: 30
|
||||
|
||||
stop_cover_tilt:
|
||||
description: Stop all or specified cover
|
||||
|
||||
description: Stop all or specified cover.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of cover(s) to stop
|
||||
description: Name(s) of cover(s) to stop.
|
||||
example: 'cover.living_room'
|
||||
|
||||
@@ -19,12 +19,12 @@ from homeassistant.const import (
|
||||
CONF_FRIENDLY_NAME, CONF_ENTITY_ID,
|
||||
EVENT_HOMEASSISTANT_START, MATCH_ALL,
|
||||
CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE,
|
||||
CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED)
|
||||
CONF_ENTITY_PICTURE_TEMPLATE, CONF_OPTIMISTIC,
|
||||
STATE_OPEN, STATE_CLOSED)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import async_generate_entity_id
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.helpers.restore_state import async_get_last_state
|
||||
from homeassistant.helpers.script import Script
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -58,6 +58,7 @@ COVER_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_POSITION_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_TILT_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_ICON_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_OPTIMISTIC): cv.boolean,
|
||||
vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean,
|
||||
vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA,
|
||||
@@ -82,6 +83,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
position_template = device_config.get(CONF_POSITION_TEMPLATE)
|
||||
tilt_template = device_config.get(CONF_TILT_TEMPLATE)
|
||||
icon_template = device_config.get(CONF_ICON_TEMPLATE)
|
||||
entity_picture_template = device_config.get(
|
||||
CONF_ENTITY_PICTURE_TEMPLATE)
|
||||
open_action = device_config.get(OPEN_ACTION)
|
||||
close_action = device_config.get(CLOSE_ACTION)
|
||||
stop_action = device_config.get(STOP_ACTION)
|
||||
@@ -115,6 +118,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
if str(temp_ids) != MATCH_ALL:
|
||||
template_entity_ids |= set(temp_ids)
|
||||
|
||||
if entity_picture_template is not None:
|
||||
temp_ids = entity_picture_template.extract_entities()
|
||||
if str(temp_ids) != MATCH_ALL:
|
||||
template_entity_ids |= set(temp_ids)
|
||||
|
||||
if not template_entity_ids:
|
||||
template_entity_ids = MATCH_ALL
|
||||
|
||||
@@ -125,8 +133,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
hass,
|
||||
device, friendly_name, state_template,
|
||||
position_template, tilt_template, icon_template,
|
||||
open_action, close_action, stop_action,
|
||||
position_action, tilt_action,
|
||||
entity_picture_template, open_action, close_action,
|
||||
stop_action, position_action, tilt_action,
|
||||
optimistic, tilt_optimistic, entity_ids
|
||||
)
|
||||
)
|
||||
@@ -134,7 +142,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
_LOGGER.error("No covers added")
|
||||
return False
|
||||
|
||||
async_add_devices(covers, True)
|
||||
async_add_devices(covers)
|
||||
return True
|
||||
|
||||
|
||||
@@ -143,8 +151,8 @@ class CoverTemplate(CoverDevice):
|
||||
|
||||
def __init__(self, hass, device_id, friendly_name, state_template,
|
||||
position_template, tilt_template, icon_template,
|
||||
open_action, close_action, stop_action,
|
||||
position_action, tilt_action,
|
||||
entity_picture_template, open_action, close_action,
|
||||
stop_action, position_action, tilt_action,
|
||||
optimistic, tilt_optimistic, entity_ids):
|
||||
"""Initialize the Template cover."""
|
||||
self.hass = hass
|
||||
@@ -155,6 +163,7 @@ class CoverTemplate(CoverDevice):
|
||||
self._position_template = position_template
|
||||
self._tilt_template = tilt_template
|
||||
self._icon_template = icon_template
|
||||
self._entity_picture_template = entity_picture_template
|
||||
self._open_script = None
|
||||
if open_action is not None:
|
||||
self._open_script = Script(hass, open_action)
|
||||
@@ -174,6 +183,7 @@ class CoverTemplate(CoverDevice):
|
||||
(not state_template and not position_template))
|
||||
self._tilt_optimistic = tilt_optimistic or not tilt_template
|
||||
self._icon = None
|
||||
self._entity_picture = None
|
||||
self._position = None
|
||||
self._tilt_value = None
|
||||
self._entities = entity_ids
|
||||
@@ -186,14 +196,12 @@ class CoverTemplate(CoverDevice):
|
||||
self._tilt_template.hass = self.hass
|
||||
if self._icon_template is not None:
|
||||
self._icon_template.hass = self.hass
|
||||
if self._entity_picture_template is not None:
|
||||
self._entity_picture_template.hass = self.hass
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
state = yield from async_get_last_state(self.hass, self.entity_id)
|
||||
if state:
|
||||
self._position = 100 if state.state == STATE_OPEN else 0
|
||||
|
||||
@callback
|
||||
def template_cover_state_listener(entity, old_state, new_state):
|
||||
"""Handle target device state changes."""
|
||||
@@ -241,6 +249,11 @@ class CoverTemplate(CoverDevice):
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def entity_picture(self):
|
||||
"""Return the entity picture to use in the frontend, if any."""
|
||||
return self._entity_picture
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
@@ -288,7 +301,7 @@ class CoverTemplate(CoverDevice):
|
||||
def async_stop_cover(self, **kwargs):
|
||||
"""Fire the stop action."""
|
||||
if self._stop_script:
|
||||
self.hass.async_add_job(self._stop_script.async_run())
|
||||
yield from self._stop_script.async_run()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_cover_position(self, **kwargs):
|
||||
@@ -374,16 +387,28 @@ class CoverTemplate(CoverDevice):
|
||||
except ValueError as ex:
|
||||
_LOGGER.error(ex)
|
||||
self._tilt_value = None
|
||||
if self._icon_template is not None:
|
||||
|
||||
for property_name, template in (
|
||||
('_icon', self._icon_template),
|
||||
('_entity_picture', self._entity_picture_template)):
|
||||
if template is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
self._icon = self._icon_template.async_render()
|
||||
setattr(self, property_name, template.async_render())
|
||||
except TemplateError as ex:
|
||||
friendly_property_name = property_name[1:].replace('_', ' ')
|
||||
if ex.args and ex.args[0].startswith(
|
||||
"UndefinedError: 'None' has no attribute"):
|
||||
# Common during HA startup - so just a warning
|
||||
_LOGGER.warning('Could not render icon template %s,'
|
||||
' the state is unknown.', self._name)
|
||||
_LOGGER.warning('Could not render %s template %s,'
|
||||
' the state is unknown.',
|
||||
friendly_property_name, self._name)
|
||||
return
|
||||
self._icon = super().icon
|
||||
_LOGGER.error('Could not render icon template %s: %s',
|
||||
self._name, ex)
|
||||
|
||||
try:
|
||||
setattr(self, property_name,
|
||||
getattr(super(), property_name))
|
||||
except AttributeError:
|
||||
_LOGGER.error('Could not render %s template %s: %s',
|
||||
friendly_property_name, self._name, ex)
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.setup import async_prepare_setup_platform
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.components import group, zone
|
||||
from homeassistant.components.discovery import SERVICE_NETGEAR
|
||||
from homeassistant.config import load_yaml_config_file, async_log_exception
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -89,10 +88,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
|
||||
cv.time_period, cv.positive_timedelta)
|
||||
})
|
||||
|
||||
DISCOVERY_PLATFORMS = {
|
||||
SERVICE_NETGEAR: 'netgear',
|
||||
}
|
||||
|
||||
|
||||
@bind_hass
|
||||
def is_on(hass: HomeAssistantType, entity_id: str=None):
|
||||
@@ -180,15 +175,6 @@ def async_setup(hass: HomeAssistantType, config: ConfigType):
|
||||
|
||||
tracker.async_setup_group()
|
||||
|
||||
@callback
|
||||
def async_device_tracker_discovered(service, info):
|
||||
"""Handle the discovery of device tracker platforms."""
|
||||
hass.async_add_job(
|
||||
async_setup_platform(DISCOVERY_PLATFORMS[service], {}, info))
|
||||
|
||||
discovery.async_listen(
|
||||
hass, DISCOVERY_PLATFORMS.keys(), async_device_tracker_discovered)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_platform_discovered(platform, info):
|
||||
"""Load a platform."""
|
||||
|
||||
@@ -12,18 +12,17 @@ from collections import namedtuple
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.device_tracker import (
|
||||
DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE,
|
||||
CONF_PROTOCOL)
|
||||
|
||||
REQUIREMENTS = ['pexpect==4.0.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_MODE = 'mode'
|
||||
CONF_PROTOCOL = 'protocol'
|
||||
CONF_PUB_KEY = 'pub_key'
|
||||
CONF_SSH_KEY = 'ssh_key'
|
||||
|
||||
@@ -36,10 +35,8 @@ PLATFORM_SCHEMA = vol.All(
|
||||
PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PROTOCOL, default='ssh'):
|
||||
vol.In(['ssh', 'telnet']),
|
||||
vol.Optional(CONF_MODE, default='router'):
|
||||
vol.In(['router', 'ap']),
|
||||
vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']),
|
||||
vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']),
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port,
|
||||
vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string,
|
||||
vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile,
|
||||
@@ -102,21 +99,18 @@ class AsusWrtDeviceScanner(DeviceScanner):
|
||||
self.success_init = False
|
||||
return
|
||||
|
||||
self.connection = SshConnection(self.host, self.port,
|
||||
self.username,
|
||||
self.password,
|
||||
self.ssh_key,
|
||||
self.mode == "ap")
|
||||
self.connection = SshConnection(
|
||||
self.host, self.port, self.username, self.password,
|
||||
self.ssh_key, self.mode == 'ap')
|
||||
else:
|
||||
if not self.password:
|
||||
_LOGGER.error("No password specified")
|
||||
self.success_init = False
|
||||
return
|
||||
|
||||
self.connection = TelnetConnection(self.host, self.port,
|
||||
self.username,
|
||||
self.password,
|
||||
self.mode == "ap")
|
||||
self.connection = TelnetConnection(
|
||||
self.host, self.port, self.username, self.password,
|
||||
self.mode == 'ap')
|
||||
|
||||
self.last_results = {}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
REQUIREMENTS = ['aioautomatic==0.6.3']
|
||||
REQUIREMENTS = ['aioautomatic==0.6.4']
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -35,7 +35,7 @@ CONF_CURRENT_LOCATION = 'current_location'
|
||||
|
||||
DEFAULT_TIMEOUT = 5
|
||||
|
||||
DEFAULT_SCOPE = ['location', 'vehicle:profile', 'trip']
|
||||
DEFAULT_SCOPE = ['location', 'trip', 'vehicle:events', 'vehicle:profile']
|
||||
FULL_SCOPE = DEFAULT_SCOPE + ['current_location']
|
||||
|
||||
ATTR_FUEL_LEVEL = 'fuel_level'
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.device_tracker import (
|
||||
DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
REQUIREMENTS = ['fritzconnection==0.6.3']
|
||||
REQUIREMENTS = ['fritzconnection==0.6.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
ATTR_CURRENT_LATITUDE = 'currentLatitude'
|
||||
ATTR_CURRENT_LONGITUDE = 'currentLongitude'
|
||||
|
||||
BEACON_DEV_PREFIX = 'beacon'
|
||||
CONF_MOBILE_BEACONS = 'mobile_beacons'
|
||||
|
||||
@@ -72,6 +75,9 @@ class GeofencyView(HomeAssistantView):
|
||||
location_name = data['name']
|
||||
else:
|
||||
location_name = STATE_NOT_HOME
|
||||
if ATTR_CURRENT_LATITUDE in data:
|
||||
data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE]
|
||||
data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE]
|
||||
|
||||
return (yield from self._set_location(hass, data, location_name))
|
||||
|
||||
@@ -96,8 +102,12 @@ class GeofencyView(HomeAssistantView):
|
||||
data['device'] = slugify(data['device'])
|
||||
data['name'] = slugify(data['name'])
|
||||
|
||||
data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE])
|
||||
data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE])
|
||||
gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE,
|
||||
ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE]
|
||||
|
||||
for attribute in gps_attributes:
|
||||
if attribute in data:
|
||||
data[attribute] = float(data[attribute])
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import (
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT)
|
||||
|
||||
REQUIREMENTS = ['librouteros==1.0.2']
|
||||
REQUIREMENTS = ['librouteros==1.0.4']
|
||||
|
||||
MTK_DEFAULT_API_PORT = '8728'
|
||||
|
||||
@@ -76,16 +76,47 @@ class MikrotikScanner(DeviceScanner):
|
||||
port=int(self.port)
|
||||
)
|
||||
|
||||
routerboard_info = self.client(cmd='/system/routerboard/getall')
|
||||
try:
|
||||
routerboard_info = self.client(
|
||||
cmd='/system/routerboard/getall')
|
||||
except (librouteros.exceptions.TrapError,
|
||||
librouteros.exceptions.MultiTrapError,
|
||||
librouteros.exceptions.ConnectionError):
|
||||
routerboard_info = None
|
||||
raise
|
||||
|
||||
if routerboard_info:
|
||||
_LOGGER.info("Connected to Mikrotik %s with IP %s",
|
||||
routerboard_info[0].get('model', 'Router'),
|
||||
self.host)
|
||||
|
||||
self.connected = True
|
||||
self.wireless_exist = self.client(
|
||||
cmd='/interface/wireless/getall'
|
||||
)
|
||||
|
||||
try:
|
||||
self.capsman_exist = self.client(
|
||||
cmd='/caps-man/interface/getall'
|
||||
)
|
||||
except (librouteros.exceptions.TrapError,
|
||||
librouteros.exceptions.MultiTrapError,
|
||||
librouteros.exceptions.ConnectionError):
|
||||
self.capsman_exist = False
|
||||
|
||||
if not self.capsman_exist:
|
||||
_LOGGER.info(
|
||||
'Mikrotik %s: Not a CAPSman controller. Trying '
|
||||
'local interfaces ',
|
||||
self.host
|
||||
)
|
||||
|
||||
try:
|
||||
self.wireless_exist = self.client(
|
||||
cmd='/interface/wireless/getall'
|
||||
)
|
||||
except (librouteros.exceptions.TrapError,
|
||||
librouteros.exceptions.MultiTrapError,
|
||||
librouteros.exceptions.ConnectionError):
|
||||
self.wireless_exist = False
|
||||
|
||||
if not self.wireless_exist:
|
||||
_LOGGER.info(
|
||||
'Mikrotik %s: Wireless adapters not found. Try to '
|
||||
@@ -95,6 +126,7 @@ class MikrotikScanner(DeviceScanner):
|
||||
)
|
||||
|
||||
except (librouteros.exceptions.TrapError,
|
||||
librouteros.exceptions.MultiTrapError,
|
||||
librouteros.exceptions.ConnectionError) as api_error:
|
||||
_LOGGER.error("Connection error: %s", api_error)
|
||||
|
||||
@@ -111,7 +143,9 @@ class MikrotikScanner(DeviceScanner):
|
||||
|
||||
def _update_info(self):
|
||||
"""Retrieve latest information from the Mikrotik box."""
|
||||
if self.wireless_exist:
|
||||
if self.capsman_exist:
|
||||
devices_tracker = 'capsman'
|
||||
elif self.wireless_exist:
|
||||
devices_tracker = 'wireless'
|
||||
else:
|
||||
devices_tracker = 'ip'
|
||||
@@ -123,7 +157,11 @@ class MikrotikScanner(DeviceScanner):
|
||||
)
|
||||
|
||||
device_names = self.client(cmd='/ip/dhcp-server/lease/getall')
|
||||
if self.wireless_exist:
|
||||
if devices_tracker == 'capsman':
|
||||
devices = self.client(
|
||||
cmd='/caps-man/registration-table/getall'
|
||||
)
|
||||
elif devices_tracker == 'wireless':
|
||||
devices = self.client(
|
||||
cmd='/interface/wireless/registration-table/getall'
|
||||
)
|
||||
|
||||
@@ -5,23 +5,22 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.owntracks/
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import base64
|
||||
from collections import defaultdict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.util import slugify, decorator
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components import zone as zone_comp
|
||||
from homeassistant.components.device_tracker import PLATFORM_SCHEMA
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.util import slugify, decorator
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
REQUIREMENTS = ['libnacl==1.5.2']
|
||||
REQUIREMENTS = ['libnacl==1.6.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +33,8 @@ CONF_SECRET = 'secret'
|
||||
CONF_WAYPOINT_IMPORT = 'waypoints'
|
||||
CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
OWNTRACKS_TOPIC = 'owntracks/#'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
@@ -74,6 +75,7 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None):
|
||||
except ValueError:
|
||||
# If invalid JSON
|
||||
_LOGGER.error("Unable to parse payload as JSON: %s", payload)
|
||||
return
|
||||
|
||||
message['topic'] = topic
|
||||
|
||||
@@ -90,7 +92,11 @@ def _parse_topic(topic):
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
_, user, device, *_ = topic.split('/', 3)
|
||||
try:
|
||||
_, user, device, *_ = topic.split('/', 3)
|
||||
except ValueError:
|
||||
_LOGGER.error("Can't parse topic: '%s'", topic)
|
||||
raise
|
||||
|
||||
return user, device
|
||||
|
||||
@@ -193,7 +199,7 @@ class OwnTracksContext:
|
||||
self.async_see = async_see
|
||||
self.secret = secret
|
||||
self.max_gps_accuracy = max_gps_accuracy
|
||||
self.mobile_beacons_active = defaultdict(list)
|
||||
self.mobile_beacons_active = defaultdict(set)
|
||||
self.regions_entered = defaultdict(list)
|
||||
self.import_waypoints = import_waypoints
|
||||
self.waypoint_whitelist = waypoint_whitelist
|
||||
@@ -228,10 +234,25 @@ class OwnTracksContext:
|
||||
return True
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_see_beacons(self, dev_id, kwargs_param):
|
||||
def async_see_beacons(self, hass, dev_id, kwargs_param):
|
||||
"""Set active beacons to the current location."""
|
||||
kwargs = kwargs_param.copy()
|
||||
|
||||
# Mobile beacons should always be set to the location of the
|
||||
# tracking device. I get the device state and make the necessary
|
||||
# changes to kwargs.
|
||||
device_tracker_state = hass.states.get(
|
||||
"device_tracker.{}".format(dev_id))
|
||||
|
||||
if device_tracker_state is not None:
|
||||
acc = device_tracker_state.attributes.get("gps_accuracy")
|
||||
lat = device_tracker_state.attributes.get("latitude")
|
||||
lon = device_tracker_state.attributes.get("longitude")
|
||||
kwargs['gps_accuracy'] = acc
|
||||
kwargs['gps'] = (lat, lon)
|
||||
|
||||
# the battery state applies to the tracking device, not the beacon
|
||||
# kwargs location is the beacon's configured lat/lon
|
||||
kwargs.pop('battery', None)
|
||||
for beacon in self.mobile_beacons_active[dev_id]:
|
||||
kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon)
|
||||
@@ -255,7 +276,7 @@ def async_handle_location_message(hass, context, message):
|
||||
return
|
||||
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(dev_id, kwargs)
|
||||
yield from context.async_see_beacons(hass, dev_id, kwargs)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
@@ -265,11 +286,15 @@ def _async_transition_message_enter(hass, context, message, location):
|
||||
dev_id, kwargs = _parse_see_args(message)
|
||||
|
||||
if zone is None and message.get('t') == 'b':
|
||||
# Not a HA zone, and a beacon so assume mobile
|
||||
# Not a HA zone, and a beacon so mobile beacon.
|
||||
# kwargs will contain the lat/lon of the beacon
|
||||
# which is not where the beacon actually is
|
||||
# and is probably set to 0/0
|
||||
beacons = context.mobile_beacons_active[dev_id]
|
||||
if location not in beacons:
|
||||
beacons.append(location)
|
||||
beacons.add(location)
|
||||
_LOGGER.info("Added beacon %s", location)
|
||||
yield from context.async_see_beacons(hass, dev_id, kwargs)
|
||||
else:
|
||||
# Normal region
|
||||
regions = context.regions_entered[dev_id]
|
||||
@@ -277,9 +302,8 @@ def _async_transition_message_enter(hass, context, message, location):
|
||||
regions.append(location)
|
||||
_LOGGER.info("Enter region %s", location)
|
||||
_set_gps_from_zone(kwargs, location, zone)
|
||||
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(dev_id, kwargs)
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(hass, dev_id, kwargs)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
@@ -291,30 +315,29 @@ def _async_transition_message_leave(hass, context, message, location):
|
||||
if location in regions:
|
||||
regions.remove(location)
|
||||
|
||||
new_region = regions[-1] if regions else None
|
||||
|
||||
if new_region:
|
||||
# Exit to previous region
|
||||
zone = hass.states.get(
|
||||
"zone.{}".format(slugify(new_region)))
|
||||
_set_gps_from_zone(kwargs, new_region, zone)
|
||||
_LOGGER.info("Exit to %s", new_region)
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(dev_id, kwargs)
|
||||
return
|
||||
|
||||
beacons = context.mobile_beacons_active[dev_id]
|
||||
if location in beacons:
|
||||
beacons.remove(location)
|
||||
_LOGGER.info("Remove beacon %s", location)
|
||||
yield from context.async_see_beacons(hass, dev_id, kwargs)
|
||||
else:
|
||||
new_region = regions[-1] if regions else None
|
||||
if new_region:
|
||||
# Exit to previous region
|
||||
zone = hass.states.get(
|
||||
"zone.{}".format(slugify(new_region)))
|
||||
_set_gps_from_zone(kwargs, new_region, zone)
|
||||
_LOGGER.info("Exit to %s", new_region)
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(hass, dev_id, kwargs)
|
||||
return
|
||||
|
||||
_LOGGER.info("Exit to GPS")
|
||||
|
||||
# Check for GPS accuracy
|
||||
if context.async_valid_accuracy(message):
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(dev_id, kwargs)
|
||||
|
||||
beacons = context.mobile_beacons_active[dev_id]
|
||||
if location in beacons:
|
||||
beacons.remove(location)
|
||||
_LOGGER.info("Remove beacon %s", location)
|
||||
yield from context.async_see_beacons(hass, dev_id, kwargs)
|
||||
|
||||
|
||||
@HANDLERS.register('transition')
|
||||
@@ -399,6 +422,13 @@ def async_handle_encrypted_message(hass, context, message):
|
||||
yield from async_handle_message(hass, context, decrypted)
|
||||
|
||||
|
||||
@HANDLERS.register('lwt')
|
||||
@asyncio.coroutine
|
||||
def async_handle_lwt_message(hass, context, message):
|
||||
"""Handle an lwt message."""
|
||||
_LOGGER.debug('Not handling lwt message: %s', message)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, context, message):
|
||||
"""Handle an OwnTracks message."""
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
# Describes the format for available device tracker services
|
||||
|
||||
see:
|
||||
description: Control tracked device
|
||||
|
||||
description: Control tracked device.
|
||||
fields:
|
||||
mac:
|
||||
description: MAC address of device
|
||||
example: 'FF:FF:FF:FF:FF:FF'
|
||||
|
||||
dev_id:
|
||||
description: Id of device (find id in known_devices.yaml)
|
||||
description: Id of device (find id in known_devices.yaml).
|
||||
example: 'phonedave'
|
||||
|
||||
host_name:
|
||||
description: Hostname of device
|
||||
example: 'Dave'
|
||||
|
||||
location_name:
|
||||
description: Name of location where device is located (not_home is away)
|
||||
description: Name of location where device is located (not_home is away).
|
||||
example: 'home'
|
||||
|
||||
gps:
|
||||
description: GPS coordinates where device is located (latitude, longitude)
|
||||
description: GPS coordinates where device is located (latitude, longitude).
|
||||
example: '[51.509802, -0.086692]'
|
||||
|
||||
gps_accuracy:
|
||||
description: Accuracy of GPS coordinates
|
||||
description: Accuracy of GPS coordinates.
|
||||
example: '80'
|
||||
|
||||
battery:
|
||||
description: Battery level of device
|
||||
description: Battery level of device.
|
||||
example: '100'
|
||||
|
||||
icloud:
|
||||
icloud_lost_iphone:
|
||||
description: Service to play the lost iphone sound on an iDevice
|
||||
|
||||
description: Service to play the lost iphone sound on an iDevice.
|
||||
fields:
|
||||
account_name:
|
||||
description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
|
||||
@@ -43,10 +35,8 @@ icloud:
|
||||
device_name:
|
||||
description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account.
|
||||
example: 'iphonebart'
|
||||
|
||||
icloud_set_interval:
|
||||
description: Service to set the interval of an iDevice
|
||||
|
||||
description: Service to set the interval of an iDevice.
|
||||
fields:
|
||||
account_name:
|
||||
description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
|
||||
@@ -57,10 +47,8 @@ icloud:
|
||||
interval:
|
||||
description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state.
|
||||
example: 1
|
||||
|
||||
icloud_update:
|
||||
description: Service to ask for an update of an iDevice.
|
||||
|
||||
fields:
|
||||
account_name:
|
||||
description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
|
||||
@@ -68,10 +56,8 @@ icloud:
|
||||
device_name:
|
||||
description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account.
|
||||
example: 'iphonebart'
|
||||
|
||||
icloud_reset_account:
|
||||
description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device.
|
||||
|
||||
fields:
|
||||
account_name:
|
||||
description: Name of the account in the config that will be restarted. This is optional, if it isn't given it will restart all accounts.
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.const import CONF_HOST
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REQUIREMENTS = ['pysnmp==4.3.9']
|
||||
REQUIREMENTS = ['pysnmp==4.4.1']
|
||||
|
||||
CONF_COMMUNITY = 'community'
|
||||
CONF_AUTHKEY = 'authkey'
|
||||
@@ -26,17 +26,17 @@ CONF_BASEOID = 'baseoid'
|
||||
DEFAULT_COMMUNITY = 'public'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_BASEOID): cv.string,
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string,
|
||||
vol.Inclusive(CONF_AUTHKEY, 'keys'): cv.string,
|
||||
vol.Inclusive(CONF_PRIVKEY, 'keys'): cv.string,
|
||||
vol.Required(CONF_BASEOID): cv.string
|
||||
})
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_scanner(hass, config):
|
||||
"""Validate the configuration and return an snmp scanner."""
|
||||
"""Validate the configuration and return an SNMP scanner."""
|
||||
scanner = SnmpScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
@@ -19,16 +19,29 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_DHCP_SOFTWARE = 'dhcp_software'
|
||||
DEFAULT_DHCP_SOFTWARE = 'dnsmasq'
|
||||
DHCP_SOFTWARES = [
|
||||
'dnsmasq',
|
||||
'odhcpd'
|
||||
]
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_DHCP_SOFTWARE,
|
||||
default=DEFAULT_DHCP_SOFTWARE): vol.In(DHCP_SOFTWARES)
|
||||
})
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
"""Validate the configuration and return an ubus scanner."""
|
||||
scanner = UbusDeviceScanner(config[DOMAIN])
|
||||
dhcp_sw = config[DOMAIN][CONF_DHCP_SOFTWARE]
|
||||
if dhcp_sw == 'dnsmasq':
|
||||
scanner = DnsmasqUbusDeviceScanner(config[DOMAIN])
|
||||
else:
|
||||
scanner = OdhcpdUbusDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
@@ -70,7 +83,6 @@ class UbusDeviceScanner(DeviceScanner):
|
||||
self.session_id = _get_session_id(self.url, self.username,
|
||||
self.password)
|
||||
self.hostapd = []
|
||||
self.leasefile = None
|
||||
self.mac2name = None
|
||||
self.success_init = self.session_id is not None
|
||||
|
||||
@@ -79,44 +91,29 @@ class UbusDeviceScanner(DeviceScanner):
|
||||
self._update_info()
|
||||
return self.last_results
|
||||
|
||||
def _generate_mac2name(self):
|
||||
"""Must be implemented depending on the software."""
|
||||
raise NotImplementedError
|
||||
|
||||
@_refresh_on_acccess_denied
|
||||
def get_device_name(self, mac):
|
||||
"""Return the name of the given device or None if we don't know."""
|
||||
if self.leasefile is None:
|
||||
result = _req_json_rpc(
|
||||
self.url, self.session_id, 'call', 'uci', 'get',
|
||||
config="dhcp", type="dnsmasq")
|
||||
if result:
|
||||
values = result["values"].values()
|
||||
self.leasefile = next(iter(values))["leasefile"]
|
||||
else:
|
||||
return
|
||||
|
||||
if self.mac2name is None:
|
||||
result = _req_json_rpc(
|
||||
self.url, self.session_id, 'call', 'file', 'read',
|
||||
path=self.leasefile)
|
||||
if result:
|
||||
self.mac2name = dict()
|
||||
for line in result["data"].splitlines():
|
||||
hosts = line.split(" ")
|
||||
self.mac2name[hosts[1].upper()] = hosts[3]
|
||||
else:
|
||||
# Error, handled in the _req_json_rpc
|
||||
return
|
||||
|
||||
return self.mac2name.get(mac.upper(), None)
|
||||
self._generate_mac2name()
|
||||
name = self.mac2name.get(mac.upper(), None)
|
||||
self.mac2name = None
|
||||
return name
|
||||
|
||||
@_refresh_on_acccess_denied
|
||||
def _update_info(self):
|
||||
"""Ensure the information from the Luci router is up to date.
|
||||
"""Ensure the information from the router is up to date.
|
||||
|
||||
Returns boolean if scanning successful.
|
||||
"""
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
_LOGGER.info("Checking ARP")
|
||||
_LOGGER.info("Checking hostapd")
|
||||
|
||||
if not self.hostapd:
|
||||
hostapd = _req_json_rpc(
|
||||
@@ -136,6 +133,57 @@ class UbusDeviceScanner(DeviceScanner):
|
||||
return bool(results)
|
||||
|
||||
|
||||
class DnsmasqUbusDeviceScanner(UbusDeviceScanner):
|
||||
"""Implement the Ubus device scanning for the dnsmasq DHCP server."""
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize the scanner."""
|
||||
super(DnsmasqUbusDeviceScanner, self).__init__(config)
|
||||
self.leasefile = None
|
||||
|
||||
def _generate_mac2name(self):
|
||||
if self.leasefile is None:
|
||||
result = _req_json_rpc(
|
||||
self.url, self.session_id, 'call', 'uci', 'get',
|
||||
config="dhcp", type="dnsmasq")
|
||||
if result:
|
||||
values = result["values"].values()
|
||||
self.leasefile = next(iter(values))["leasefile"]
|
||||
else:
|
||||
return
|
||||
|
||||
result = _req_json_rpc(
|
||||
self.url, self.session_id, 'call', 'file', 'read',
|
||||
path=self.leasefile)
|
||||
if result:
|
||||
self.mac2name = dict()
|
||||
for line in result["data"].splitlines():
|
||||
hosts = line.split(" ")
|
||||
self.mac2name[hosts[1].upper()] = hosts[3]
|
||||
else:
|
||||
# Error, handled in the _req_json_rpc
|
||||
return
|
||||
|
||||
|
||||
class OdhcpdUbusDeviceScanner(UbusDeviceScanner):
|
||||
"""Implement the Ubus device scanning for the odhcp DHCP server."""
|
||||
|
||||
def _generate_mac2name(self):
|
||||
result = _req_json_rpc(
|
||||
self.url, self.session_id, 'call', 'dhcp', 'ipv4leases')
|
||||
if result:
|
||||
self.mac2name = dict()
|
||||
for device in result["device"].values():
|
||||
for lease in device['leases']:
|
||||
mac = lease['mac'] # mac = aabbccddeeff
|
||||
# Convert it to expected format with colon
|
||||
mac = ":".join(mac[i:i+2] for i in range(0, len(mac), 2))
|
||||
self.mac2name[mac.upper()] = lease['hostname']
|
||||
else:
|
||||
# Error, handled in the _req_json_rpc
|
||||
return
|
||||
|
||||
|
||||
def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params):
|
||||
"""Perform one JSON RPC operation."""
|
||||
data = json.dumps({"jsonrpc": "2.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.unifi/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
@@ -12,16 +13,19 @@ from homeassistant.components.device_tracker import (
|
||||
DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.const import CONF_VERIFY_SSL
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
REQUIREMENTS = ['pyunifi==2.13']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CONF_PORT = 'port'
|
||||
CONF_SITE_ID = 'site_id'
|
||||
CONF_DETECTION_TIME = 'detection_time'
|
||||
|
||||
DEFAULT_HOST = 'localhost'
|
||||
DEFAULT_PORT = 8443
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
DEFAULT_DETECTION_TIME = timedelta(seconds=300)
|
||||
|
||||
NOTIFICATION_ID = 'unifi_notification'
|
||||
NOTIFICATION_TITLE = 'Unifi Device Tracker Setup'
|
||||
@@ -32,7 +36,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): vol.Any(
|
||||
cv.boolean, cv.isfile),
|
||||
vol.Optional(CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME): vol.All(
|
||||
cv.time_period, cv.positive_timedelta)
|
||||
})
|
||||
|
||||
|
||||
@@ -46,6 +53,7 @@ def get_scanner(hass, config):
|
||||
site_id = config[DOMAIN].get(CONF_SITE_ID)
|
||||
port = config[DOMAIN].get(CONF_PORT)
|
||||
verify_ssl = config[DOMAIN].get(CONF_VERIFY_SSL)
|
||||
detection_time = config[DOMAIN].get(CONF_DETECTION_TIME)
|
||||
|
||||
try:
|
||||
ctrl = Controller(host, username, password, port, version='v4',
|
||||
@@ -61,14 +69,15 @@ def get_scanner(hass, config):
|
||||
notification_id=NOTIFICATION_ID)
|
||||
return False
|
||||
|
||||
return UnifiScanner(ctrl)
|
||||
return UnifiScanner(ctrl, detection_time)
|
||||
|
||||
|
||||
class UnifiScanner(DeviceScanner):
|
||||
"""Provide device_tracker support from Unifi WAP client data."""
|
||||
|
||||
def __init__(self, controller):
|
||||
def __init__(self, controller, detection_time: timedelta):
|
||||
"""Initialize the scanner."""
|
||||
self._detection_time = detection_time
|
||||
self._controller = controller
|
||||
self._update()
|
||||
|
||||
@@ -81,7 +90,11 @@ class UnifiScanner(DeviceScanner):
|
||||
_LOGGER.error("Failed to scan clients: %s", ex)
|
||||
clients = []
|
||||
|
||||
self._clients = {client['mac']: client for client in clients}
|
||||
self._clients = {
|
||||
client['mac']: client
|
||||
for client in clients
|
||||
if (dt_util.utcnow() - dt_util.utc_from_timestamp(float(
|
||||
client['last_seen']))) < self._detection_time}
|
||||
|
||||
def scan_devices(self):
|
||||
"""Scan for devices."""
|
||||
@@ -96,5 +109,5 @@ class UnifiScanner(DeviceScanner):
|
||||
"""
|
||||
client = self._clients.get(mac, {})
|
||||
name = client.get('name') or client.get('hostname')
|
||||
_LOGGER.debug("Device %s name %s", mac, name)
|
||||
_LOGGER.debug("Device mac %s name %s", mac, name)
|
||||
return name
|
||||
|
||||
@@ -6,7 +6,6 @@ https://home-assistant.io/components/device_tracker.upc_connect/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
@@ -19,6 +18,8 @@ from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
|
||||
REQUIREMENTS = ['defusedxml==0.5.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_IP = '192.168.0.1'
|
||||
@@ -63,6 +64,8 @@ class UPCDeviceScanner(DeviceScanner):
|
||||
@asyncio.coroutine
|
||||
def async_scan_devices(self):
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
if self.token is None:
|
||||
token_initialized = yield from self.async_initialize_token()
|
||||
if not token_initialized:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Support for API.AI webhook.
|
||||
Support for Dialogflow webhook.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/apiai/
|
||||
https://home-assistant.io/components/dialogflow/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -15,17 +15,16 @@ from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
INTENTS_API_ENDPOINT = '/api/apiai'
|
||||
|
||||
CONF_INTENTS = 'intents'
|
||||
CONF_SPEECH = 'speech'
|
||||
CONF_ACTION = 'action'
|
||||
CONF_ASYNC_ACTION = 'async_action'
|
||||
|
||||
DEFAULT_CONF_ASYNC_ACTION = False
|
||||
|
||||
DOMAIN = 'apiai'
|
||||
DEPENDENCIES = ['http']
|
||||
DOMAIN = 'dialogflow'
|
||||
|
||||
INTENTS_API_ENDPOINT = '/api/dialogflow'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: {}
|
||||
@@ -34,30 +33,30 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Activate API.AI component."""
|
||||
hass.http.register_view(ApiaiIntentsView)
|
||||
"""Set up Dialogflow component."""
|
||||
hass.http.register_view(DialogflowIntentsView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ApiaiIntentsView(HomeAssistantView):
|
||||
"""Handle API.AI requests."""
|
||||
class DialogflowIntentsView(HomeAssistantView):
|
||||
"""Handle Dialogflow requests."""
|
||||
|
||||
url = INTENTS_API_ENDPOINT
|
||||
name = 'api:apiai'
|
||||
name = 'api:dialogflow'
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Handle API.AI."""
|
||||
"""Handle Dialogflow."""
|
||||
hass = request.app['hass']
|
||||
data = yield from request.json()
|
||||
|
||||
_LOGGER.debug("Received api.ai request: %s", data)
|
||||
_LOGGER.debug("Received Dialogflow request: %s", data)
|
||||
|
||||
req = data.get('result')
|
||||
|
||||
if req is None:
|
||||
_LOGGER.error("Received invalid data from api.ai: %s", data)
|
||||
_LOGGER.error("Received invalid data from Dialogflow: %s", data)
|
||||
return self.json_message(
|
||||
"Expected result value not received", HTTP_BAD_REQUEST)
|
||||
|
||||
@@ -68,13 +67,13 @@ class ApiaiIntentsView(HomeAssistantView):
|
||||
|
||||
action = req.get('action')
|
||||
parameters = req.get('parameters')
|
||||
apiai_response = ApiaiResponse(parameters)
|
||||
dialogflow_response = DialogflowResponse(parameters)
|
||||
|
||||
if action == "":
|
||||
_LOGGER.warning("Received intent with empty action")
|
||||
apiai_response.add_speech(
|
||||
"You have not defined an action in your api.ai intent.")
|
||||
return self.json(apiai_response)
|
||||
dialogflow_response.add_speech(
|
||||
"You have not defined an action in your Dialogflow intent.")
|
||||
return self.json(dialogflow_response)
|
||||
|
||||
try:
|
||||
intent_response = yield from intent.async_handle(
|
||||
@@ -83,31 +82,31 @@ class ApiaiIntentsView(HomeAssistantView):
|
||||
in parameters.items()})
|
||||
|
||||
except intent.UnknownIntent as err:
|
||||
_LOGGER.warning('Received unknown intent %s', action)
|
||||
apiai_response.add_speech(
|
||||
_LOGGER.warning("Received unknown intent %s", action)
|
||||
dialogflow_response.add_speech(
|
||||
"This intent is not yet configured within Home Assistant.")
|
||||
return self.json(apiai_response)
|
||||
return self.json(dialogflow_response)
|
||||
|
||||
except intent.InvalidSlotInfo as err:
|
||||
_LOGGER.error('Received invalid slot data: %s', err)
|
||||
_LOGGER.error("Received invalid slot data: %s", err)
|
||||
return self.json_message('Invalid slot data received',
|
||||
HTTP_BAD_REQUEST)
|
||||
except intent.IntentError:
|
||||
_LOGGER.exception('Error handling request for %s', action)
|
||||
_LOGGER.exception("Error handling request for %s", action)
|
||||
return self.json_message('Error handling intent', HTTP_BAD_REQUEST)
|
||||
|
||||
if 'plain' in intent_response.speech:
|
||||
apiai_response.add_speech(
|
||||
dialogflow_response.add_speech(
|
||||
intent_response.speech['plain']['speech'])
|
||||
|
||||
return self.json(apiai_response)
|
||||
return self.json(dialogflow_response)
|
||||
|
||||
|
||||
class ApiaiResponse(object):
|
||||
"""Help generating the response for API.AI."""
|
||||
class DialogflowResponse(object):
|
||||
"""Help generating the response for Dialogflow."""
|
||||
|
||||
def __init__(self, parameters):
|
||||
"""Initialize the response."""
|
||||
"""Initialize the Dialogflow response."""
|
||||
self.speech = None
|
||||
self.parameters = {}
|
||||
# Parameter names replace '.' and '-' for '_'
|
||||
@@ -125,7 +124,7 @@ class ApiaiResponse(object):
|
||||
self.speech = text
|
||||
|
||||
def as_dict(self):
|
||||
"""Return response in an API.AI valid dict."""
|
||||
"""Return response in a Dialogflow valid dictionary."""
|
||||
return {
|
||||
'speech': self.speech,
|
||||
'displayText': self.speech,
|
||||
@@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
from homeassistant.helpers.discovery import async_load_platform, async_discover
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
REQUIREMENTS = ['netdisco==1.2.2']
|
||||
REQUIREMENTS = ['netdisco==1.2.3']
|
||||
|
||||
DOMAIN = 'discovery'
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.util import sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_FILENAME = 'filename'
|
||||
ATTR_SUBDIR = 'subdir'
|
||||
ATTR_URL = 'url'
|
||||
|
||||
@@ -29,6 +30,7 @@ SERVICE_DOWNLOAD_FILE = 'download_file'
|
||||
SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_URL): cv.url,
|
||||
vol.Optional(ATTR_SUBDIR): cv.string,
|
||||
vol.Optional(ATTR_FILENAME): cv.string,
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
@@ -62,6 +64,8 @@ def setup(hass, config):
|
||||
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
filename = service.data.get(ATTR_FILENAME)
|
||||
|
||||
if subdir:
|
||||
subdir = sanitize_filename(subdir)
|
||||
|
||||
@@ -70,9 +74,9 @@ def setup(hass, config):
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code == 200:
|
||||
filename = None
|
||||
|
||||
if 'content-disposition' in req.headers:
|
||||
if filename is None and \
|
||||
'content-disposition' in req.headers:
|
||||
match = re.findall(r"filename=(\S+)",
|
||||
req.headers['content-disposition'])
|
||||
|
||||
@@ -80,8 +84,7 @@ def setup(hass, config):
|
||||
filename = match[0].strip("'\" ")
|
||||
|
||||
if not filename:
|
||||
filename = os.path.basename(
|
||||
url).strip()
|
||||
filename = os.path.basename(url).strip()
|
||||
|
||||
if not filename:
|
||||
filename = 'ha_download'
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Integrate with DuckDNS."""
|
||||
"""
|
||||
Integrate with DuckDNS.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/duckdns/
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
@@ -11,13 +16,18 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
DOMAIN = 'duckdns'
|
||||
UPDATE_URL = 'https://www.duckdns.org/update'
|
||||
INTERVAL = timedelta(minutes=5)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SERVICE_SET_TXT = 'set_txt'
|
||||
|
||||
ATTR_TXT = 'txt'
|
||||
|
||||
DOMAIN = 'duckdns'
|
||||
|
||||
INTERVAL = timedelta(minutes=5)
|
||||
|
||||
SERVICE_SET_TXT = 'set_txt'
|
||||
|
||||
UPDATE_URL = 'https://www.duckdns.org/update'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_DOMAIN): cv.string,
|
||||
@@ -59,8 +69,8 @@ def async_setup(hass, config):
|
||||
@asyncio.coroutine
|
||||
def update_domain_service(call):
|
||||
"""Update the DuckDNS entry."""
|
||||
yield from _update_duckdns(session, domain, token,
|
||||
txt=call.data[ATTR_TXT])
|
||||
yield from _update_duckdns(
|
||||
session, domain, token, txt=call.data[ATTR_TXT])
|
||||
|
||||
async_track_time_interval(hass, update_domain_interval, INTERVAL)
|
||||
hass.services.async_register(
|
||||
@@ -96,7 +106,7 @@ def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False):
|
||||
body = yield from resp.text()
|
||||
|
||||
if body != 'OK':
|
||||
_LOGGER.warning('Updating DuckDNS domain %s failed', domain)
|
||||
_LOGGER.warning("Updating DuckDNS domain failed: %s", domain)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -76,7 +76,6 @@ def setup(hass, yaml_config):
|
||||
|
||||
server = HomeAssistantWSGI(
|
||||
hass,
|
||||
development=False,
|
||||
server_host=config.host_ip_addr,
|
||||
server_port=config.listen_port,
|
||||
api_password=None,
|
||||
|
||||
@@ -287,6 +287,11 @@ def parse_hue_api_put_light_body(request_json, entity):
|
||||
report_brightness = True
|
||||
result = (brightness > 0)
|
||||
|
||||
elif entity.domain == "scene":
|
||||
brightness = None
|
||||
report_brightness = False
|
||||
result = True
|
||||
|
||||
elif (entity.domain == "script" or
|
||||
entity.domain == "media_player" or
|
||||
entity.domain == "fan"):
|
||||
|
||||
@@ -11,7 +11,7 @@ import voluptuous as vol
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['enocean==0.31']
|
||||
REQUIREMENTS = ['enocean==0.40']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -215,20 +215,12 @@ def async_setup(hass, config: dict):
|
||||
target_fans = component.async_extract_from_service(service)
|
||||
params.pop(ATTR_ENTITY_ID, None)
|
||||
|
||||
update_tasks = []
|
||||
for fan in target_fans:
|
||||
yield from getattr(fan, method['method'])(**params)
|
||||
|
||||
update_tasks = []
|
||||
|
||||
for fan in target_fans:
|
||||
if not fan.should_poll:
|
||||
continue
|
||||
|
||||
update_coro = hass.async_add_job(fan.async_update_ha_state(True))
|
||||
if hasattr(fan, 'async_update'):
|
||||
update_tasks.append(update_coro)
|
||||
else:
|
||||
yield from update_coro
|
||||
update_tasks.append(fan.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF,
|
||||
SPEED_LOW, SPEED_MEDIUM,
|
||||
SPEED_HIGH)
|
||||
import homeassistant.components.isy994 as isy
|
||||
from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF
|
||||
from homeassistant.const import STATE_ON, STATE_OFF
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -73,19 +73,16 @@ class ISYFanDevice(isy.ISYDevice, FanEntity):
|
||||
@property
|
||||
def speed(self) -> str:
|
||||
"""Return the current speed."""
|
||||
return self.state
|
||||
return VALUE_TO_STATE.get(self.value)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Get the state of the ISY994 fan device."""
|
||||
return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN)
|
||||
def is_on(self) -> str:
|
||||
"""Get if the fan is on."""
|
||||
return self.value != 0
|
||||
|
||||
def set_speed(self, speed: str) -> None:
|
||||
"""Send the set speed command to the ISY994 fan device."""
|
||||
if not self._node.on(val=STATE_TO_VALUE.get(speed, 0)):
|
||||
_LOGGER.debug("Unable to set fan speed")
|
||||
else:
|
||||
self.speed = self.state
|
||||
self._node.on(val=STATE_TO_VALUE.get(speed, 255))
|
||||
|
||||
def turn_on(self, speed: str=None, **kwargs) -> None:
|
||||
"""Send the turn on command to the ISY994 fan device."""
|
||||
@@ -93,10 +90,7 @@ class ISYFanDevice(isy.ISYDevice, FanEntity):
|
||||
|
||||
def turn_off(self, **kwargs) -> None:
|
||||
"""Send the turn off command to the ISY994 fan device."""
|
||||
if not self._node.off():
|
||||
_LOGGER.debug("Unable to set fan speed")
|
||||
else:
|
||||
self.speed = self.state
|
||||
self._node.off()
|
||||
|
||||
@property
|
||||
def speed_list(self) -> list:
|
||||
|
||||
@@ -186,7 +186,7 @@ class MqttFan(FanEntity):
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_SPEED_STATE_TOPIC], speed_received,
|
||||
self._qos)
|
||||
self._speed = SPEED_OFF
|
||||
self._speed = SPEED_OFF
|
||||
|
||||
@callback
|
||||
def oscillation_received(topic, payload, qos):
|
||||
@@ -202,7 +202,7 @@ class MqttFan(FanEntity):
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_OSCILLATION_STATE_TOPIC],
|
||||
oscillation_received, self._qos)
|
||||
self._oscillation = False
|
||||
self._oscillation = False
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
|
||||
@@ -1,60 +1,51 @@
|
||||
# Describes the format for available fan services
|
||||
|
||||
set_speed:
|
||||
description: Sets fan speed
|
||||
|
||||
description: Sets fan speed.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of the entities to set
|
||||
example: 'fan.living_room'
|
||||
|
||||
speed:
|
||||
description: Speed setting
|
||||
example: 'low'
|
||||
|
||||
turn_on:
|
||||
description: Turns fan on
|
||||
|
||||
description: Turns fan on.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Names(s) of the entities to turn on
|
||||
example: 'fan.living_room'
|
||||
|
||||
speed:
|
||||
description: Speed setting
|
||||
example: 'high'
|
||||
|
||||
turn_off:
|
||||
description: Turns fan off
|
||||
|
||||
description: Turns fan off.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Names(s) of the entities to turn off
|
||||
example: 'fan.living_room'
|
||||
|
||||
oscillate:
|
||||
description: Oscillates the fan
|
||||
|
||||
description: Oscillates the fan.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of the entities to oscillate
|
||||
example: 'fan.desk_fan'
|
||||
|
||||
oscillating:
|
||||
description: Flag to turn on/off oscillation
|
||||
example: True
|
||||
|
||||
toggle:
|
||||
description: Toggle the fan on/off
|
||||
|
||||
description: Toggle the fan on/off.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of the entities to toggle
|
||||
exampl: 'fan.living_room'
|
||||
|
||||
set_direction:
|
||||
description: Set the fan rotation direction
|
||||
|
||||
description: Set the fan rotation.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of the entities to toggle
|
||||
@@ -64,8 +55,7 @@ set_direction:
|
||||
example: 'left'
|
||||
|
||||
dyson_set_night_mode:
|
||||
description: Set the fan in night mode
|
||||
|
||||
description: Set the fan in night mode.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of the entities to enable/disable night mode
|
||||
|
||||
332
homeassistant/components/fan/xiaomi_miio.py
Normal file
332
homeassistant/components/fan/xiaomi_miio.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Support for Xiaomi Mi Air Purifier 2.
|
||||
|
||||
For more details about this platform, please refer to the documentation
|
||||
https://home-assistant.io/components/fan.xiaomi_miio/
|
||||
"""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA,
|
||||
SUPPORT_SET_SPEED, DOMAIN)
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN,
|
||||
ATTR_ENTITY_ID, )
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'Xiaomi Air Purifier'
|
||||
PLATFORM = 'xiaomi_miio'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
})
|
||||
|
||||
REQUIREMENTS = ['python-miio==0.3.0']
|
||||
|
||||
ATTR_TEMPERATURE = 'temperature'
|
||||
ATTR_HUMIDITY = 'humidity'
|
||||
ATTR_AIR_QUALITY_INDEX = 'aqi'
|
||||
ATTR_MODE = 'mode'
|
||||
ATTR_FILTER_HOURS_USED = 'filter_hours_used'
|
||||
ATTR_FILTER_LIFE = 'filter_life_remaining'
|
||||
ATTR_FAVORITE_LEVEL = 'favorite_level'
|
||||
ATTR_BUZZER = 'buzzer'
|
||||
ATTR_CHILD_LOCK = 'child_lock'
|
||||
ATTR_LED = 'led'
|
||||
ATTR_LED_BRIGHTNESS = 'led_brightness'
|
||||
ATTR_MOTOR_SPEED = 'motor_speed'
|
||||
|
||||
ATTR_BRIGHTNESS = 'brightness'
|
||||
ATTR_LEVEL = 'level'
|
||||
|
||||
SUCCESS = ['ok']
|
||||
|
||||
SERVICE_SET_BUZZER_ON = 'xiaomi_miio_set_buzzer_on'
|
||||
SERVICE_SET_BUZZER_OFF = 'xiaomi_miio_set_buzzer_off'
|
||||
SERVICE_SET_LED_ON = 'xiaomi_miio_set_led_on'
|
||||
SERVICE_SET_LED_OFF = 'xiaomi_miio_set_led_off'
|
||||
SERVICE_SET_FAVORITE_LEVEL = 'xiaomi_miio_set_favorite_level'
|
||||
SERVICE_SET_LED_BRIGHTNESS = 'xiaomi_miio_set_led_brightness'
|
||||
|
||||
AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_LED_BRIGHTNESS = AIRPURIFIER_SERVICE_SCHEMA.extend({
|
||||
vol.Required(ATTR_BRIGHTNESS):
|
||||
vol.All(vol.Coerce(int), vol.Clamp(min=0, max=2))
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend({
|
||||
vol.Required(ATTR_LEVEL):
|
||||
vol.All(vol.Coerce(int), vol.Clamp(min=0, max=16))
|
||||
})
|
||||
|
||||
SERVICE_TO_METHOD = {
|
||||
SERVICE_SET_BUZZER_ON: {'method': 'async_set_buzzer_on'},
|
||||
SERVICE_SET_BUZZER_OFF: {'method': 'async_set_buzzer_off'},
|
||||
SERVICE_SET_LED_ON: {'method': 'async_set_led_on'},
|
||||
SERVICE_SET_LED_OFF: {'method': 'async_set_led_off'},
|
||||
SERVICE_SET_FAVORITE_LEVEL: {
|
||||
'method': 'async_set_favorite_level',
|
||||
'schema': SERVICE_SCHEMA_FAVORITE_LEVEL},
|
||||
SERVICE_SET_LED_BRIGHTNESS: {
|
||||
'method': 'async_set_led_brightness',
|
||||
'schema': SERVICE_SCHEMA_LED_BRIGHTNESS},
|
||||
}
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the air purifier from config."""
|
||||
from miio import AirPurifier, DeviceException
|
||||
if PLATFORM not in hass.data:
|
||||
hass.data[PLATFORM] = {}
|
||||
|
||||
host = config.get(CONF_HOST)
|
||||
name = config.get(CONF_NAME)
|
||||
token = config.get(CONF_TOKEN)
|
||||
|
||||
_LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
|
||||
|
||||
try:
|
||||
air_purifier = AirPurifier(host, token)
|
||||
|
||||
xiaomi_air_purifier = XiaomiAirPurifier(name, air_purifier)
|
||||
hass.data[PLATFORM][host] = xiaomi_air_purifier
|
||||
except DeviceException:
|
||||
raise PlatformNotReady
|
||||
|
||||
async_add_devices([xiaomi_air_purifier], update_before_add=True)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_service_handler(service):
|
||||
"""Map services to methods on XiaomiAirPurifier."""
|
||||
method = SERVICE_TO_METHOD.get(service.service)
|
||||
params = {key: value for key, value in service.data.items()
|
||||
if key != ATTR_ENTITY_ID}
|
||||
entity_ids = service.data.get(ATTR_ENTITY_ID)
|
||||
if entity_ids:
|
||||
target_air_purifiers = [air for air in hass.data[PLATFORM].values()
|
||||
if air.entity_id in entity_ids]
|
||||
else:
|
||||
target_air_purifiers = hass.data[PLATFORM].values()
|
||||
|
||||
update_tasks = []
|
||||
for air_purifier in target_air_purifiers:
|
||||
yield from getattr(air_purifier, method['method'])(**params)
|
||||
update_tasks.append(air_purifier.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
descriptions = yield from hass.async_add_job(
|
||||
load_yaml_config_file, os.path.join(
|
||||
os.path.dirname(__file__), 'xiaomi_miio_services.yaml'))
|
||||
|
||||
for air_purifier_service in SERVICE_TO_METHOD:
|
||||
schema = SERVICE_TO_METHOD[air_purifier_service].get(
|
||||
'schema', AIRPURIFIER_SERVICE_SCHEMA)
|
||||
hass.services.async_register(
|
||||
DOMAIN, air_purifier_service, async_service_handler,
|
||||
description=descriptions.get(air_purifier_service), schema=schema)
|
||||
|
||||
|
||||
class XiaomiAirPurifier(FanEntity):
|
||||
"""Representation of a Xiaomi Air Purifier."""
|
||||
|
||||
def __init__(self, name, air_purifier):
|
||||
"""Initialize the air purifier."""
|
||||
self._name = name
|
||||
|
||||
self._air_purifier = air_purifier
|
||||
self._state = None
|
||||
self._state_attrs = {
|
||||
ATTR_AIR_QUALITY_INDEX: None,
|
||||
ATTR_TEMPERATURE: None,
|
||||
ATTR_HUMIDITY: None,
|
||||
ATTR_MODE: None,
|
||||
ATTR_FILTER_HOURS_USED: None,
|
||||
ATTR_FILTER_LIFE: None,
|
||||
ATTR_FAVORITE_LEVEL: None,
|
||||
ATTR_BUZZER: None,
|
||||
ATTR_CHILD_LOCK: None,
|
||||
ATTR_LED: None,
|
||||
ATTR_LED_BRIGHTNESS: None,
|
||||
ATTR_MOTOR_SPEED: None
|
||||
}
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_SET_SPEED
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Poll the fan."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return true when state is known."""
|
||||
return self._state is not None
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes of the device."""
|
||||
return self._state_attrs
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if fan is on."""
|
||||
return self._state
|
||||
|
||||
@asyncio.coroutine
|
||||
def _try_command(self, mask_error, func, *args, **kwargs):
|
||||
"""Call a air purifier command handling error messages."""
|
||||
from miio import DeviceException
|
||||
try:
|
||||
result = yield from self.hass.async_add_job(
|
||||
partial(func, *args, **kwargs))
|
||||
|
||||
_LOGGER.debug("Response received from air purifier: %s", result)
|
||||
|
||||
return result == SUCCESS
|
||||
except DeviceException as exc:
|
||||
_LOGGER.error(mask_error, exc)
|
||||
return False
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None:
|
||||
"""Turn the fan on."""
|
||||
if speed:
|
||||
# If operation mode was set the device must not be turned on.
|
||||
yield from self.async_set_speed(speed)
|
||||
return
|
||||
|
||||
yield from self._try_command(
|
||||
"Turning the air purifier on failed.", self._air_purifier.on)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_off(self: ToggleEntity, **kwargs) -> None:
|
||||
"""Turn the fan off."""
|
||||
yield from self._try_command(
|
||||
"Turning the air purifier off failed.", self._air_purifier.off)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
"""Fetch state from the device."""
|
||||
from miio import DeviceException
|
||||
|
||||
try:
|
||||
state = yield from self.hass.async_add_job(
|
||||
self._air_purifier.status)
|
||||
_LOGGER.debug("Got new state: %s", state)
|
||||
|
||||
self._state = state.is_on
|
||||
self._state_attrs = {
|
||||
ATTR_TEMPERATURE: state.temperature,
|
||||
ATTR_HUMIDITY: state.humidity,
|
||||
ATTR_AIR_QUALITY_INDEX: state.aqi,
|
||||
ATTR_MODE: state.mode.value,
|
||||
ATTR_FILTER_HOURS_USED: state.filter_hours_used,
|
||||
ATTR_FILTER_LIFE: state.filter_life_remaining,
|
||||
ATTR_FAVORITE_LEVEL: state.favorite_level,
|
||||
ATTR_BUZZER: state.buzzer,
|
||||
ATTR_CHILD_LOCK: state.child_lock,
|
||||
ATTR_LED: state.led,
|
||||
ATTR_MOTOR_SPEED: state.motor_speed
|
||||
}
|
||||
|
||||
if state.led_brightness:
|
||||
self._state_attrs[
|
||||
ATTR_LED_BRIGHTNESS] = state.led_brightness.value
|
||||
|
||||
except DeviceException as ex:
|
||||
_LOGGER.error("Got exception while fetching the state: %s", ex)
|
||||
|
||||
@property
|
||||
def speed_list(self: ToggleEntity) -> list:
|
||||
"""Get the list of available speeds."""
|
||||
from miio.airpurifier import OperationMode
|
||||
return [mode.name for mode in OperationMode]
|
||||
|
||||
@property
|
||||
def speed(self):
|
||||
"""Return the current speed."""
|
||||
if self._state:
|
||||
from miio.airpurifier import OperationMode
|
||||
|
||||
return OperationMode(self._state_attrs[ATTR_MODE]).name
|
||||
|
||||
return None
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_speed(self: ToggleEntity, speed: str) -> None:
|
||||
"""Set the speed of the fan."""
|
||||
_LOGGER.debug("Setting the operation mode to: " + speed)
|
||||
from miio.airpurifier import OperationMode
|
||||
|
||||
yield from self._try_command(
|
||||
"Setting operation mode of the air purifier failed.",
|
||||
self._air_purifier.set_mode, OperationMode[speed])
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_buzzer_on(self):
|
||||
"""Turn the buzzer on."""
|
||||
yield from self._try_command(
|
||||
"Turning the buzzer of air purifier on failed.",
|
||||
self._air_purifier.set_buzzer, True)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_buzzer_off(self):
|
||||
"""Turn the buzzer on."""
|
||||
yield from self._try_command(
|
||||
"Turning the buzzer of air purifier off failed.",
|
||||
self._air_purifier.set_buzzer, False)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_led_on(self):
|
||||
"""Turn the led on."""
|
||||
yield from self._try_command(
|
||||
"Turning the led of air purifier off failed.",
|
||||
self._air_purifier.set_led, True)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_led_off(self):
|
||||
"""Turn the led off."""
|
||||
yield from self._try_command(
|
||||
"Turning the led of air purifier off failed.",
|
||||
self._air_purifier.set_led, False)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_led_brightness(self, brightness: int=2):
|
||||
"""Set the led brightness."""
|
||||
from miio.airpurifier import LedBrightness
|
||||
|
||||
yield from self._try_command(
|
||||
"Setting the led brightness of the air purifier failed.",
|
||||
self._air_purifier.set_led_brightness, LedBrightness(brightness))
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_favorite_level(self, level: int=1):
|
||||
"""Set the favorite level."""
|
||||
yield from self._try_command(
|
||||
"Setting the favorite level of the air purifier failed.",
|
||||
self._air_purifier.set_favorite_level, level)
|
||||
56
homeassistant/components/fan/xiaomi_miio_services.yaml
Normal file
56
homeassistant/components/fan/xiaomi_miio_services.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
|
||||
xiaomi_miio_set_buzzer_on:
|
||||
description: Turn the buzzer on.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the air purifier entity.
|
||||
example: 'fan.xiaomi_air_purifier'
|
||||
|
||||
xiaomi_miio_set_buzzer_off:
|
||||
description: Turn the buzzer off.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the air purifier entity.
|
||||
example: 'fan.xiaomi_air_purifier'
|
||||
|
||||
xiaomi_miio_set_led_on:
|
||||
description: Turn the led on.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the air purifier entity.
|
||||
example: 'fan.xiaomi_air_purifier'
|
||||
|
||||
xiaomi_miio_set_led_off:
|
||||
description: Turn the led off.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the air purifier entity.
|
||||
example: 'fan.xiaomi_air_purifier'
|
||||
|
||||
xiaomi_miio_set_favorite_level:
|
||||
description: Set the favorite level.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the air purifier entity.
|
||||
example: 'fan.xiaomi_air_purifier'
|
||||
|
||||
level:
|
||||
description: Level, between 0 and 16.
|
||||
example: '1'
|
||||
|
||||
xiaomi_miio_set_led_brightness:
|
||||
description: Set the led brightness.
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the air purifier entity.
|
||||
example: 'fan.xiaomi_air_purifier'
|
||||
|
||||
brightness:
|
||||
description: Brightness (0 = Bright, 1 = Dim, 2 = Off)
|
||||
example: '1'
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.dispatcher import (
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
REQUIREMENTS = ['ha-ffmpeg==1.7']
|
||||
REQUIREMENTS = ['ha-ffmpeg==1.9']
|
||||
|
||||
DOMAIN = 'ffmpeg'
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Handle the frontend for Home Assistant."""
|
||||
"""
|
||||
Handle the frontend for Home Assistant.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/frontend/
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
@@ -7,17 +12,16 @@ import os
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http.auth import is_trusted_ip
|
||||
from homeassistant.config import find_config_file, load_yaml_config_file
|
||||
from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.components import api
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http.auth import is_trusted_ip
|
||||
from homeassistant.components.http.const import KEY_DEVELOPMENT
|
||||
from .version import FINGERPRINTS
|
||||
|
||||
REQUIREMENTS = ['home-assistant-frontend==20171105.0']
|
||||
|
||||
DOMAIN = 'frontend'
|
||||
DEPENDENCIES = ['api', 'websocket_api']
|
||||
@@ -25,11 +29,16 @@ DEPENDENCIES = ['api', 'websocket_api']
|
||||
URL_PANEL_COMPONENT = '/frontend/panels/{}.html'
|
||||
URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html'
|
||||
|
||||
STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/')
|
||||
POLYMER_PATH = os.path.join(os.path.dirname(__file__),
|
||||
'home-assistant-polymer/')
|
||||
FINAL_PATH = os.path.join(POLYMER_PATH, 'final')
|
||||
|
||||
CONF_THEMES = 'themes'
|
||||
CONF_EXTRA_HTML_URL = 'extra_html_url'
|
||||
CONF_FRONTEND_REPO = 'development_repo'
|
||||
|
||||
ATTR_THEMES = 'themes'
|
||||
ATTR_EXTRA_HTML_URL = 'extra_html_url'
|
||||
DEFAULT_THEME_COLOR = '#03A9F4'
|
||||
|
||||
MANIFEST_JSON = {
|
||||
'background_color': '#FFFFFF',
|
||||
'description': 'Open-source home automation platform running on Python 3.',
|
||||
@@ -50,9 +59,9 @@ for size in (192, 384, 512, 1024):
|
||||
'type': 'image/png'
|
||||
})
|
||||
|
||||
DATA_FINALIZE_PANEL = 'frontend_finalize_panel'
|
||||
DATA_PANELS = 'frontend_panels'
|
||||
DATA_EXTRA_HTML_URL = 'frontend_extra_html_url'
|
||||
DATA_INDEX_VIEW = 'frontend_index_view'
|
||||
DATA_THEMES = 'frontend_themes'
|
||||
DATA_DEFAULT_THEME = 'frontend_default_theme'
|
||||
DEFAULT_THEME = 'default'
|
||||
@@ -60,15 +69,16 @@ DEFAULT_THEME = 'default'
|
||||
PRIMARY_COLOR = 'primary-color'
|
||||
|
||||
# To keep track we don't register a component twice (gives a warning)
|
||||
_REGISTERED_COMPONENTS = set()
|
||||
# _REGISTERED_COMPONENTS = set()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(ATTR_THEMES): vol.Schema({
|
||||
vol.Optional(CONF_FRONTEND_REPO): cv.isdir,
|
||||
vol.Optional(CONF_THEMES): vol.Schema({
|
||||
cv.string: {cv.string: cv.string}
|
||||
}),
|
||||
vol.Optional(ATTR_EXTRA_HTML_URL):
|
||||
vol.Optional(CONF_EXTRA_HTML_URL):
|
||||
vol.All(cv.ensure_list, [cv.string]),
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
@@ -80,101 +90,175 @@ SERVICE_SET_THEME_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
class AbstractPanel:
|
||||
"""Abstract class for panels."""
|
||||
|
||||
# Name of the webcomponent
|
||||
component_name = None
|
||||
|
||||
# Icon to show in the sidebar (optional)
|
||||
sidebar_icon = None
|
||||
|
||||
# Title to show in the sidebar (optional)
|
||||
sidebar_title = None
|
||||
|
||||
# Url to the webcomponent
|
||||
webcomponent_url = None
|
||||
|
||||
# Url to show the panel in the frontend
|
||||
frontend_url_path = None
|
||||
|
||||
# Config to pass to the webcomponent
|
||||
config = None
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_register(self, hass):
|
||||
"""Register panel with HASS."""
|
||||
panels = hass.data.get(DATA_PANELS)
|
||||
if panels is None:
|
||||
panels = hass.data[DATA_PANELS] = {}
|
||||
|
||||
if self.frontend_url_path in panels:
|
||||
_LOGGER.warning("Overwriting component %s", self.frontend_url_path)
|
||||
|
||||
if DATA_FINALIZE_PANEL in hass.data:
|
||||
yield from hass.data[DATA_FINALIZE_PANEL](self)
|
||||
|
||||
panels[self.frontend_url_path] = self
|
||||
|
||||
@callback
|
||||
def async_register_index_routes(self, router, index_view):
|
||||
"""Register routes for panel to be served by index view."""
|
||||
router.add_route(
|
||||
'get', '/{}'.format(self.frontend_url_path), index_view.get)
|
||||
router.add_route(
|
||||
'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path),
|
||||
index_view.get)
|
||||
|
||||
def as_dict(self):
|
||||
"""Panel as dictionary."""
|
||||
return {
|
||||
'component_name': self.component_name,
|
||||
'icon': self.sidebar_icon,
|
||||
'title': self.sidebar_title,
|
||||
'url': self.webcomponent_url,
|
||||
'url_path': self.frontend_url_path,
|
||||
'config': self.config,
|
||||
}
|
||||
|
||||
|
||||
class BuiltInPanel(AbstractPanel):
|
||||
"""Panel that is part of hass_frontend."""
|
||||
|
||||
def __init__(self, component_name, sidebar_title, sidebar_icon,
|
||||
frontend_url_path, config):
|
||||
"""Initialize a built-in panel."""
|
||||
self.component_name = component_name
|
||||
self.sidebar_title = sidebar_title
|
||||
self.sidebar_icon = sidebar_icon
|
||||
self.frontend_url_path = frontend_url_path or component_name
|
||||
self.config = config
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_finalize(self, hass, frontend_repository_path):
|
||||
"""Finalize this panel for usage.
|
||||
|
||||
If frontend_repository_path is set, will be prepended to path of
|
||||
built-in components.
|
||||
"""
|
||||
panel_path = 'panels/ha-panel-{}.html'.format(self.component_name)
|
||||
|
||||
if frontend_repository_path is None:
|
||||
import hass_frontend
|
||||
|
||||
self.webcomponent_url = \
|
||||
'/static/panels/ha-panel-{}-{}.html'.format(
|
||||
self.component_name,
|
||||
hass_frontend.FINGERPRINTS[panel_path])
|
||||
|
||||
else:
|
||||
# Dev mode
|
||||
self.webcomponent_url = \
|
||||
'/home-assistant-polymer/panels/{}/ha-panel-{}.html'.format(
|
||||
self.component_name, self.component_name)
|
||||
|
||||
|
||||
class ExternalPanel(AbstractPanel):
|
||||
"""Panel that is added by a custom component."""
|
||||
|
||||
REGISTERED_COMPONENTS = set()
|
||||
|
||||
def __init__(self, component_name, path, md5, sidebar_title, sidebar_icon,
|
||||
frontend_url_path, config):
|
||||
"""Initialize an external panel."""
|
||||
self.component_name = component_name
|
||||
self.path = path
|
||||
self.md5 = md5
|
||||
self.sidebar_title = sidebar_title
|
||||
self.sidebar_icon = sidebar_icon
|
||||
self.frontend_url_path = frontend_url_path or component_name
|
||||
self.config = config
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_finalize(self, hass, frontend_repository_path):
|
||||
"""Finalize this panel for usage.
|
||||
|
||||
frontend_repository_path is set, will be prepended to path of built-in
|
||||
components.
|
||||
"""
|
||||
try:
|
||||
if self.md5 is None:
|
||||
yield from hass.async_add_job(_fingerprint, self.path)
|
||||
except OSError:
|
||||
_LOGGER.error('Cannot find or access %s at %s',
|
||||
self.component_name, self.path)
|
||||
hass.data[DATA_PANELS].pop(self.frontend_url_path)
|
||||
|
||||
self.webcomponent_url = \
|
||||
URL_PANEL_COMPONENT_FP.format(self.component_name, self.md5)
|
||||
|
||||
if self.component_name not in self.REGISTERED_COMPONENTS:
|
||||
hass.http.register_static_path(
|
||||
self.webcomponent_url, self.path,
|
||||
# if path is None, we're in prod mode, so cache static assets
|
||||
frontend_repository_path is None)
|
||||
self.REGISTERED_COMPONENTS.add(self.component_name)
|
||||
|
||||
|
||||
@bind_hass
|
||||
def register_built_in_panel(hass, component_name, sidebar_title=None,
|
||||
sidebar_icon=None, url_path=None, config=None):
|
||||
@asyncio.coroutine
|
||||
def async_register_built_in_panel(hass, component_name, sidebar_title=None,
|
||||
sidebar_icon=None, frontend_url_path=None,
|
||||
config=None):
|
||||
"""Register a built-in panel."""
|
||||
nondev_path = 'panels/ha-panel-{}.html'.format(component_name)
|
||||
|
||||
if hass.http.development:
|
||||
url = ('/static/home-assistant-polymer/panels/'
|
||||
'{0}/ha-panel-{0}.html'.format(component_name))
|
||||
path = os.path.join(
|
||||
STATIC_PATH, 'home-assistant-polymer/panels/',
|
||||
'{0}/ha-panel-{0}.html'.format(component_name))
|
||||
else:
|
||||
url = None # use default url generate mechanism
|
||||
path = os.path.join(STATIC_PATH, nondev_path)
|
||||
|
||||
# Fingerprint doesn't exist when adding new built-in panel
|
||||
register_panel(hass, component_name, path,
|
||||
FINGERPRINTS.get(nondev_path, 'dev'), sidebar_title,
|
||||
sidebar_icon, url_path, url, config)
|
||||
panel = BuiltInPanel(component_name, sidebar_title, sidebar_icon,
|
||||
frontend_url_path, config)
|
||||
yield from panel.async_register(hass)
|
||||
|
||||
|
||||
@bind_hass
|
||||
def register_panel(hass, component_name, path, md5=None, sidebar_title=None,
|
||||
sidebar_icon=None, url_path=None, url=None, config=None):
|
||||
@asyncio.coroutine
|
||||
def async_register_panel(hass, component_name, path, md5=None,
|
||||
sidebar_title=None, sidebar_icon=None,
|
||||
frontend_url_path=None, config=None):
|
||||
"""Register a panel for the frontend.
|
||||
|
||||
component_name: name of the web component
|
||||
path: path to the HTML of the web component
|
||||
(required unless url is provided)
|
||||
md5: the md5 hash of the web component (for versioning, optional)
|
||||
md5: the md5 hash of the web component (for versioning in url, optional)
|
||||
sidebar_title: title to show in the sidebar (optional)
|
||||
sidebar_icon: icon to show next to title in sidebar (optional)
|
||||
url_path: name to use in the url (defaults to component_name)
|
||||
url: for the web component (optional)
|
||||
config: config to be passed into the web component
|
||||
"""
|
||||
panels = hass.data.get(DATA_PANELS)
|
||||
if panels is None:
|
||||
panels = hass.data[DATA_PANELS] = {}
|
||||
|
||||
if url_path is None:
|
||||
url_path = component_name
|
||||
|
||||
if url_path in panels:
|
||||
_LOGGER.warning("Overwriting component %s", url_path)
|
||||
|
||||
if url is None:
|
||||
if not os.path.isfile(path):
|
||||
_LOGGER.error(
|
||||
"Panel %s component does not exist: %s", component_name, path)
|
||||
return
|
||||
|
||||
if md5 is None:
|
||||
with open(path) as fil:
|
||||
md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest()
|
||||
|
||||
data = {
|
||||
'url_path': url_path,
|
||||
'component_name': component_name,
|
||||
}
|
||||
|
||||
if sidebar_title:
|
||||
data['title'] = sidebar_title
|
||||
if sidebar_icon:
|
||||
data['icon'] = sidebar_icon
|
||||
if config is not None:
|
||||
data['config'] = config
|
||||
|
||||
if url is not None:
|
||||
data['url'] = url
|
||||
else:
|
||||
url = URL_PANEL_COMPONENT.format(component_name)
|
||||
|
||||
if url not in _REGISTERED_COMPONENTS:
|
||||
hass.http.register_static_path(url, path)
|
||||
_REGISTERED_COMPONENTS.add(url)
|
||||
|
||||
fprinted_url = URL_PANEL_COMPONENT_FP.format(component_name, md5)
|
||||
data['url'] = fprinted_url
|
||||
|
||||
panels[url_path] = data
|
||||
|
||||
# Register index view for this route if IndexView already loaded
|
||||
# Otherwise it will be done during setup.
|
||||
index_view = hass.data.get(DATA_INDEX_VIEW)
|
||||
|
||||
if index_view:
|
||||
hass.http.app.router.add_route(
|
||||
'get', '/{}'.format(url_path), index_view.get)
|
||||
hass.http.app.router.add_route(
|
||||
'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get)
|
||||
panel = ExternalPanel(component_name, path, md5, sidebar_title,
|
||||
sidebar_icon, frontend_url_path, config)
|
||||
yield from panel.async_register(hass)
|
||||
|
||||
|
||||
@bind_hass
|
||||
@callback
|
||||
def add_extra_html_url(hass, url):
|
||||
"""Register extra html url to load."""
|
||||
url_set = hass.data.get(DATA_EXTRA_HTML_URL)
|
||||
@@ -188,59 +272,74 @@ def add_manifest_json_key(key, val):
|
||||
MANIFEST_JSON[key] = val
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Set up the serving of the frontend."""
|
||||
hass.http.register_view(BootstrapView)
|
||||
hass.http.register_view(ManifestJSONView)
|
||||
|
||||
if hass.http.development:
|
||||
sw_path = "home-assistant-polymer/build/service_worker.js"
|
||||
else:
|
||||
sw_path = "service_worker.js"
|
||||
conf = config.get(DOMAIN, {})
|
||||
|
||||
hass.http.register_static_path("/service_worker.js",
|
||||
os.path.join(STATIC_PATH, sw_path), False)
|
||||
hass.http.register_static_path("/robots.txt",
|
||||
os.path.join(STATIC_PATH, "robots.txt"))
|
||||
hass.http.register_static_path("/static", STATIC_PATH)
|
||||
repo_path = conf.get(CONF_FRONTEND_REPO)
|
||||
is_dev = repo_path is not None
|
||||
|
||||
if is_dev:
|
||||
hass.http.register_static_path(
|
||||
"/home-assistant-polymer", repo_path, False)
|
||||
hass.http.register_static_path(
|
||||
"/static/translations",
|
||||
os.path.join(repo_path, "build/translations"), False)
|
||||
sw_path = os.path.join(repo_path, "build/service_worker.js")
|
||||
static_path = os.path.join(repo_path, 'hass_frontend')
|
||||
else:
|
||||
import hass_frontend
|
||||
frontend_path = hass_frontend.where()
|
||||
sw_path = os.path.join(frontend_path, "service_worker.js")
|
||||
static_path = frontend_path
|
||||
|
||||
hass.http.register_static_path("/service_worker.js", sw_path, False)
|
||||
hass.http.register_static_path(
|
||||
"/robots.txt", os.path.join(static_path, "robots.txt"), not is_dev)
|
||||
hass.http.register_static_path("/static", static_path, not is_dev)
|
||||
|
||||
local = hass.config.path('www')
|
||||
if os.path.isdir(local):
|
||||
hass.http.register_static_path("/local", local)
|
||||
hass.http.register_static_path("/local", local, not is_dev)
|
||||
|
||||
index_view = hass.data[DATA_INDEX_VIEW] = IndexView()
|
||||
index_view = IndexView(is_dev)
|
||||
hass.http.register_view(index_view)
|
||||
|
||||
# Components have registered panels before frontend got setup.
|
||||
# Now register their urls.
|
||||
if DATA_PANELS in hass.data:
|
||||
for url_path in hass.data[DATA_PANELS]:
|
||||
hass.http.app.router.add_route(
|
||||
'get', '/{}'.format(url_path), index_view.get)
|
||||
hass.http.app.router.add_route(
|
||||
'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get)
|
||||
else:
|
||||
hass.data[DATA_PANELS] = {}
|
||||
@asyncio.coroutine
|
||||
def finalize_panel(panel):
|
||||
"""Finalize setup of a panel."""
|
||||
yield from panel.async_finalize(hass, repo_path)
|
||||
panel.async_register_index_routes(hass.http.app.router, index_view)
|
||||
|
||||
yield from asyncio.wait([
|
||||
async_register_built_in_panel(hass, panel)
|
||||
for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state',
|
||||
'dev-template', 'dev-mqtt', 'kiosk')], loop=hass.loop)
|
||||
|
||||
hass.data[DATA_FINALIZE_PANEL] = finalize_panel
|
||||
|
||||
# Finalize registration of panels that registered before frontend was setup
|
||||
# This includes the built-in panels from line above.
|
||||
yield from asyncio.wait(
|
||||
[finalize_panel(panel) for panel in hass.data[DATA_PANELS].values()],
|
||||
loop=hass.loop)
|
||||
|
||||
if DATA_EXTRA_HTML_URL not in hass.data:
|
||||
hass.data[DATA_EXTRA_HTML_URL] = set()
|
||||
|
||||
register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location')
|
||||
|
||||
for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state',
|
||||
'dev-template', 'dev-mqtt', 'kiosk'):
|
||||
register_built_in_panel(hass, panel)
|
||||
|
||||
themes = config.get(DOMAIN, {}).get(ATTR_THEMES)
|
||||
setup_themes(hass, themes)
|
||||
|
||||
for url in config.get(DOMAIN, {}).get(ATTR_EXTRA_HTML_URL, []):
|
||||
for url in conf.get(CONF_EXTRA_HTML_URL, []):
|
||||
add_extra_html_url(hass, url)
|
||||
|
||||
yield from async_setup_themes(hass, conf.get(CONF_THEMES))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup_themes(hass, themes):
|
||||
@asyncio.coroutine
|
||||
def async_setup_themes(hass, themes):
|
||||
"""Set up themes data and services."""
|
||||
hass.http.register_view(ThemesView)
|
||||
hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
|
||||
@@ -280,40 +379,22 @@ def setup_themes(hass, themes):
|
||||
def reload_themes(_):
|
||||
"""Reload themes."""
|
||||
path = find_config_file(hass.config.config_dir)
|
||||
new_themes = load_yaml_config_file(path)[DOMAIN].get(ATTR_THEMES, {})
|
||||
new_themes = load_yaml_config_file(path)[DOMAIN].get(CONF_THEMES, {})
|
||||
hass.data[DATA_THEMES] = new_themes
|
||||
if hass.data[DATA_DEFAULT_THEME] not in new_themes:
|
||||
hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
|
||||
update_theme_and_fire_event()
|
||||
|
||||
descriptions = load_yaml_config_file(
|
||||
descriptions = yield from hass.async_add_job(
|
||||
load_yaml_config_file,
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
hass.services.register(DOMAIN, SERVICE_SET_THEME,
|
||||
set_theme,
|
||||
descriptions[SERVICE_SET_THEME],
|
||||
SERVICE_SET_THEME_SCHEMA)
|
||||
hass.services.register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes,
|
||||
descriptions[SERVICE_RELOAD_THEMES])
|
||||
|
||||
|
||||
class BootstrapView(HomeAssistantView):
|
||||
"""View to bootstrap frontend with all needed data."""
|
||||
|
||||
url = '/api/bootstrap'
|
||||
name = 'api:bootstrap'
|
||||
|
||||
@callback
|
||||
def get(self, request):
|
||||
"""Return all data needed to bootstrap Home Assistant."""
|
||||
hass = request.app['hass']
|
||||
|
||||
return self.json({
|
||||
'config': hass.config.as_dict(),
|
||||
'states': hass.states.async_all(),
|
||||
'events': api.async_events_json(hass),
|
||||
'services': api.async_services_json(hass),
|
||||
'panels': hass.data[DATA_PANELS],
|
||||
})
|
||||
hass.services.async_register(DOMAIN, SERVICE_SET_THEME,
|
||||
set_theme,
|
||||
descriptions[SERVICE_SET_THEME],
|
||||
SERVICE_SET_THEME_SCHEMA)
|
||||
hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes,
|
||||
descriptions[SERVICE_RELOAD_THEMES])
|
||||
|
||||
|
||||
class IndexView(HomeAssistantView):
|
||||
@@ -324,10 +405,11 @@ class IndexView(HomeAssistantView):
|
||||
requires_auth = False
|
||||
extra_urls = ['/states', '/states/{extra}']
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, use_repo):
|
||||
"""Initialize the frontend view."""
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
self.use_repo = use_repo
|
||||
self.templates = Environment(
|
||||
autoescape=True,
|
||||
loader=FileSystemLoader(
|
||||
@@ -340,18 +422,23 @@ class IndexView(HomeAssistantView):
|
||||
"""Serve the index view."""
|
||||
hass = request.app['hass']
|
||||
|
||||
if request.app[KEY_DEVELOPMENT]:
|
||||
core_url = '/static/home-assistant-polymer/build/core.js'
|
||||
if self.use_repo:
|
||||
core_url = '/home-assistant-polymer/build/core.js'
|
||||
compatibility_url = \
|
||||
'/static/home-assistant-polymer/build/compatibility.js'
|
||||
ui_url = '/static/home-assistant-polymer/src/home-assistant.html'
|
||||
'/home-assistant-polymer/build/compatibility.js'
|
||||
ui_url = '/home-assistant-polymer/src/home-assistant.html'
|
||||
icons_fp = ''
|
||||
icons_url = '/static/mdi.html'
|
||||
else:
|
||||
import hass_frontend
|
||||
core_url = '/static/core-{}.js'.format(
|
||||
FINGERPRINTS['core.js'])
|
||||
hass_frontend.FINGERPRINTS['core.js'])
|
||||
compatibility_url = '/static/compatibility-{}.js'.format(
|
||||
FINGERPRINTS['compatibility.js'])
|
||||
hass_frontend.FINGERPRINTS['compatibility.js'])
|
||||
ui_url = '/static/frontend-{}.html'.format(
|
||||
FINGERPRINTS['frontend.html'])
|
||||
hass_frontend.FINGERPRINTS['frontend.html'])
|
||||
icons_fp = '-{}'.format(hass_frontend.FINGERPRINTS['mdi.html'])
|
||||
icons_url = '/static/mdi{}.html'.format(icons_fp)
|
||||
|
||||
if request.path == '/':
|
||||
panel = 'states'
|
||||
@@ -361,17 +448,13 @@ class IndexView(HomeAssistantView):
|
||||
if panel == 'states':
|
||||
panel_url = ''
|
||||
else:
|
||||
panel_url = hass.data[DATA_PANELS][panel]['url']
|
||||
panel_url = hass.data[DATA_PANELS][panel].webcomponent_url
|
||||
|
||||
no_auth = 'true'
|
||||
if hass.config.api.api_password:
|
||||
# require password if set
|
||||
if hass.config.api.api_password and not is_trusted_ip(request):
|
||||
# do not try to auto connect on load
|
||||
no_auth = 'false'
|
||||
if is_trusted_ip(request):
|
||||
# bypass for trusted networks
|
||||
no_auth = 'true'
|
||||
|
||||
icons_url = '/static/mdi-{}.html'.format(FINGERPRINTS['mdi.html'])
|
||||
template = yield from hass.async_add_job(
|
||||
self.templates.get_template, 'index.html')
|
||||
|
||||
@@ -381,9 +464,9 @@ class IndexView(HomeAssistantView):
|
||||
resp = template.render(
|
||||
core_url=core_url, ui_url=ui_url,
|
||||
compatibility_url=compatibility_url, no_auth=no_auth,
|
||||
icons_url=icons_url, icons=FINGERPRINTS['mdi.html'],
|
||||
icons_url=icons_url, icons=icons_fp,
|
||||
panel_url=panel_url, panels=hass.data[DATA_PANELS],
|
||||
dev_mode=request.app[KEY_DEVELOPMENT],
|
||||
dev_mode=self.use_repo,
|
||||
theme_color=MANIFEST_JSON['theme_color'],
|
||||
extra_urls=hass.data[DATA_EXTRA_HTML_URL])
|
||||
|
||||
@@ -420,3 +503,9 @@ class ThemesView(HomeAssistantView):
|
||||
'themes': hass.data[DATA_THEMES],
|
||||
'default_theme': hass.data[DATA_DEFAULT_THEME],
|
||||
})
|
||||
|
||||
|
||||
def _fingerprint(path):
|
||||
"""Fingerprint a file."""
|
||||
with open(path) as fil:
|
||||
return hashlib.md5(fil.read().encode('utf-8')).hexdigest()
|
||||
|
||||
@@ -8,4 +8,4 @@ set_theme:
|
||||
example: 'light'
|
||||
|
||||
reload_themes:
|
||||
description: Reload themes from yaml config.
|
||||
description: Reload themes from yaml configuration.
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
<link rel='icon' href='/static/icons/favicon.ico'>
|
||||
<link rel='apple-touch-icon' sizes='180x180'
|
||||
href='/static/icons/favicon-apple-180x180.png'>
|
||||
<link rel="mask-icon" href="/static/icons/home-assistant-icon.svg" color="#3fbbf4">
|
||||
<link rel='preload' href='{{ core_url }}' as='script'/>
|
||||
{% for panel in panels.values() -%}
|
||||
<link rel='prefetch' href='{{ panel.url }}'>
|
||||
{% endfor -%}
|
||||
<link rel="mask-icon" href="/static/icons/mask-icon.svg" color="#3fbbf4">
|
||||
{% if not dev_mode %}
|
||||
<link rel='preload' href='{{ core_url }}' as='script'/>
|
||||
{% for panel in panels.values() -%}
|
||||
<link rel='prefetch' href='{{ panel.webcomponent_url }}'>
|
||||
{% endfor -%}
|
||||
{% endif %}
|
||||
<meta name='apple-mobile-web-app-capable' content='yes'>
|
||||
<meta name="msapplication-square70x70logo" content="/static/icons/tile-win-70x70.png"/>
|
||||
<meta name="msapplication-square150x150logo" content="/static/icons/tile-win-150x150.png"/>
|
||||
@@ -36,7 +38,7 @@
|
||||
display: block;
|
||||
content: "";
|
||||
height: 48px;
|
||||
background-color: #03A9F4;
|
||||
background-color: {{ theme_color }};
|
||||
}
|
||||
|
||||
#ha-init-skeleton .message {
|
||||
@@ -50,7 +52,7 @@
|
||||
}
|
||||
|
||||
#ha-init-skeleton a {
|
||||
color: #03A9F4;
|
||||
color: {{ theme_color }};
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""DO NOT MODIFY. Auto-generated by script/fingerprint_frontend."""
|
||||
|
||||
FINGERPRINTS = {
|
||||
"compatibility.js": "1686167ff210e001f063f5c606b2e74b",
|
||||
"core.js": "2a7d01e45187c7d4635da05065b5e54e",
|
||||
"frontend.html": "2de1bde3b4a6c6c47dd95504fc098906",
|
||||
"mdi.html": "2e848b4da029bf73d426d5ba058a088d",
|
||||
"micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a",
|
||||
"panels/ha-panel-config.html": "52e2e1d477bfd6dc3708d65b8337f0af",
|
||||
"panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c",
|
||||
"panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0",
|
||||
"panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6",
|
||||
"panels/ha-panel-dev-service.html": "422b2c181ee0713fa31d45a64e605baf",
|
||||
"panels/ha-panel-dev-state.html": "7948d3dba058f31517d880df8ed0e857",
|
||||
"panels/ha-panel-dev-template.html": "928e7b81b9c113b70edc9f4a1d051827",
|
||||
"panels/ha-panel-hassio.html": "b46e7619f3c355f872d5370741d89f6a",
|
||||
"panels/ha-panel-history.html": "fe2daac10a14f51fa3eb7d23978df1f7",
|
||||
"panels/ha-panel-iframe.html": "56930204d6e067a3d600cf030f4b34c8",
|
||||
"panels/ha-panel-kiosk.html": "b40aa5cb52dd7675bea744afcf9eebf8",
|
||||
"panels/ha-panel-logbook.html": "771afdcf48dc7e308b0282417d2e02d8",
|
||||
"panels/ha-panel-mailbox.html": "a8cca44ca36553e91565e3c894ea6323",
|
||||
"panels/ha-panel-map.html": "565db019147162080c21af962afc097f",
|
||||
"panels/ha-panel-shopping-list.html": "d8cfd0ecdb3aa6214c0f6908c34c7141"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user