forked from home-assistant/core
Compare commits
347 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc16157af | ||
|
|
35d7f2b8bb | ||
|
|
7390f82e1f | ||
|
|
cc9e5de503 | ||
|
|
50c8224365 | ||
|
|
b08b376aa7 | ||
|
|
60ef0153a2 | ||
|
|
44c4b25f2b | ||
|
|
4abcaea4b7 | ||
|
|
831cad4220 | ||
|
|
6c524594c1 | ||
|
|
78f6cfd1eb | ||
|
|
6d6abab358 | ||
|
|
326cc83a17 | ||
|
|
8358ab56ea | ||
|
|
32dc518971 | ||
|
|
b318a033bb | ||
|
|
a0b2105ea0 | ||
|
|
9f9b87692a | ||
|
|
5c4f04e9fc | ||
|
|
757f6278eb | ||
|
|
b9dcc2777b | ||
|
|
103fffa0f4 | ||
|
|
7748867732 | ||
|
|
02517ae5ec | ||
|
|
2a31bb48c6 | ||
|
|
5b70ada7b4 | ||
|
|
7b45cf8e59 | ||
|
|
394d53e748 | ||
|
|
c125c4af4f | ||
|
|
f90b89bc74 | ||
|
|
ceac9eab94 | ||
|
|
7bb0abdf09 | ||
|
|
1d60760e21 | ||
|
|
43d18daebd | ||
|
|
1a7895b1d8 | ||
|
|
c2f31bbb38 | ||
|
|
a7e75dd01e | ||
|
|
58ea3c25df | ||
|
|
6d2de67620 | ||
|
|
a359d21799 | ||
|
|
be552a59c9 | ||
|
|
832f9737a8 | ||
|
|
da6bdf275e | ||
|
|
7ca025f653 | ||
|
|
570cfc60c5 | ||
|
|
dc551b825f | ||
|
|
6da3e23436 | ||
|
|
e4b6395250 | ||
|
|
72bd9fb5c7 | ||
|
|
2dec38d8d4 | ||
|
|
acb841a1f4 | ||
|
|
eeb8bc3913 | ||
|
|
12f790c7cf | ||
|
|
dbb4e4c3fa | ||
|
|
d51e62d0a3 | ||
|
|
ab92a91ac5 | ||
|
|
cfa36f3546 | ||
|
|
96d8fbe513 | ||
|
|
1e9d91be0e | ||
|
|
2402897f47 | ||
|
|
b857d5dad0 | ||
|
|
d17753009a | ||
|
|
3467020dbf | ||
|
|
4114884cdc | ||
|
|
d7ccf07922 | ||
|
|
2a7fa5afc3 | ||
|
|
04aa4e898a | ||
|
|
b156ae7812 | ||
|
|
48928d1f9e | ||
|
|
df98d5b3c1 | ||
|
|
f4b5c439a1 | ||
|
|
ecc514b7e4 | ||
|
|
6edb54052f | ||
|
|
4d2480bbd1 | ||
|
|
2708e193ec | ||
|
|
c3923b2768 | ||
|
|
080c4efb00 | ||
|
|
99f1ea9b59 | ||
|
|
46cad514d4 | ||
|
|
e0552ad899 | ||
|
|
5c99dd0e3d | ||
|
|
cdf9464698 | ||
|
|
7ba25f3526 | ||
|
|
ee5b9e7291 | ||
|
|
167260bcc6 | ||
|
|
64de1c9777 | ||
|
|
1547045f2c | ||
|
|
d02899216d | ||
|
|
0aac4d64e1 | ||
|
|
0bf9e6d4bb | ||
|
|
f78246e686 | ||
|
|
c90a1b9760 | ||
|
|
14446c5731 | ||
|
|
2e2b764dbe | ||
|
|
695f062e29 | ||
|
|
194b268ae3 | ||
|
|
8295fc8b4c | ||
|
|
d0dcd1bb73 | ||
|
|
82ad8b0a8f | ||
|
|
91a9da8f0c | ||
|
|
e3415c4e22 | ||
|
|
9bca3f3103 | ||
|
|
7c3ae884df | ||
|
|
8a4aace789 | ||
|
|
0e74cd833d | ||
|
|
5e2911f071 | ||
|
|
7dacc4a7bb | ||
|
|
98fe50d5ad | ||
|
|
37e3c2a133 | ||
|
|
76b79019ce | ||
|
|
c40ddf18c7 | ||
|
|
9a3fe691b1 | ||
|
|
8826e6a8d0 | ||
|
|
860a12cffb | ||
|
|
76ff934bd3 | ||
|
|
d968e1d011 | ||
|
|
fa0dbaf065 | ||
|
|
4d0f19496a | ||
|
|
0cc9555d14 | ||
|
|
d712a3dc38 | ||
|
|
84446bed14 | ||
|
|
e92b15f966 | ||
|
|
a458ce8069 | ||
|
|
5e492db9a3 | ||
|
|
bc646070c8 | ||
|
|
64290d74f0 | ||
|
|
a11b68c560 | ||
|
|
8ca2345fd4 | ||
|
|
8c628071f3 | ||
|
|
81d38c3463 | ||
|
|
776455030f | ||
|
|
8afd30b7d4 | ||
|
|
b60f5714fc | ||
|
|
fa8bc0a36c | ||
|
|
1ae8256ffd | ||
|
|
b3253403aa | ||
|
|
308744d8a0 | ||
|
|
13006cee68 | ||
|
|
e21382cd3e | ||
|
|
71fc446425 | ||
|
|
03d19ec2f1 | ||
|
|
5a7e446646 | ||
|
|
2b3caa716a | ||
|
|
6574dd8439 | ||
|
|
6d56519297 | ||
|
|
60bcb12a48 | ||
|
|
58509f8bba | ||
|
|
2099d023ef | ||
|
|
db6a6fa4cb | ||
|
|
d89bfcdaa5 | ||
|
|
64b1179c13 | ||
|
|
840e27adec | ||
|
|
31a8537ab5 | ||
|
|
87dab37b8a | ||
|
|
3a2cdd3de0 | ||
|
|
2009e98497 | ||
|
|
b354a18bf3 | ||
|
|
1cd3cd8d77 | ||
|
|
d9556392bc | ||
|
|
695fb412cd | ||
|
|
93322b0251 | ||
|
|
9b9b625ac4 | ||
|
|
0ae6585a90 | ||
|
|
cffc7ac4d8 | ||
|
|
a9be6c36f1 | ||
|
|
1b35f0878e | ||
|
|
93872590b6 | ||
|
|
b2a15e17d3 | ||
|
|
9bf13231f7 | ||
|
|
c8c6bee539 | ||
|
|
b5c2be8ffa | ||
|
|
4d35f2805f | ||
|
|
53c1b93b61 | ||
|
|
c25aa56751 | ||
|
|
e8c9dcf0fe | ||
|
|
ca63e44227 | ||
|
|
776e53a7f0 | ||
|
|
a099430834 | ||
|
|
7746ecd98e | ||
|
|
10d1496f5a | ||
|
|
cf0ff54d14 | ||
|
|
97cc76b43e | ||
|
|
c89e6ec915 | ||
|
|
efdf51b542 | ||
|
|
bbb251c0cf | ||
|
|
94b719e150 | ||
|
|
69d3a3dd32 | ||
|
|
4904653b70 | ||
|
|
dddf4d1460 | ||
|
|
9a6c9cff30 | ||
|
|
d3b62e1fe1 | ||
|
|
f63a79ee8f | ||
|
|
898ba56d9f | ||
|
|
64a5bff5b2 | ||
|
|
af7de8d5ae | ||
|
|
754d98bcd5 | ||
|
|
4874030b70 | ||
|
|
84c89686a9 | ||
|
|
48fd8f1f63 | ||
|
|
83a108b20a | ||
|
|
b0a800cc6d | ||
|
|
1f5f4e7a89 | ||
|
|
b1fbada02d | ||
|
|
08909ed420 | ||
|
|
ec8969351d | ||
|
|
801a69be3a | ||
|
|
51e20c92f9 | ||
|
|
443553ff16 | ||
|
|
2e6a48ff5f | ||
|
|
49cfe38cca | ||
|
|
8a042586f1 | ||
|
|
08f8e540e3 | ||
|
|
f09b888a8a | ||
|
|
279f82acc4 | ||
|
|
de6c5a503b | ||
|
|
898f89ffc7 | ||
|
|
5c807c6bd9 | ||
|
|
dd84b4e237 | ||
|
|
6dfae7a259 | ||
|
|
c6c8cd4f51 | ||
|
|
bde7176b3c | ||
|
|
4c03d670c1 | ||
|
|
406afbb369 | ||
|
|
9c6609cb79 | ||
|
|
e5504b39ec | ||
|
|
b1ef5042f9 | ||
|
|
b35fa4f1c1 | ||
|
|
71da9d2f50 | ||
|
|
86388f5af2 | ||
|
|
17f0fb69bd | ||
|
|
2d02baf3d0 | ||
|
|
66473120ab | ||
|
|
6ddbb4d568 | ||
|
|
154c69a454 | ||
|
|
ad4ec49f9c | ||
|
|
e8367f245a | ||
|
|
4bc37bd661 | ||
|
|
b4841a17a6 | ||
|
|
3b9d5cdf73 | ||
|
|
77d568dc47 | ||
|
|
9db1ff8cd4 | ||
|
|
248a90b71d | ||
|
|
d8c4af9c81 | ||
|
|
1e6c660f59 | ||
|
|
44a508e86c | ||
|
|
92c6cee2a1 | ||
|
|
d4bc8e23af | ||
|
|
f0db698f75 | ||
|
|
cf57db919e | ||
|
|
84b12ab007 | ||
|
|
601193b1d2 | ||
|
|
038b1c1fc6 | ||
|
|
0d734303a4 | ||
|
|
ff4cb23f2a | ||
|
|
e94b4ec006 | ||
|
|
be91207830 | ||
|
|
ecf285105c | ||
|
|
767f3d58ff | ||
|
|
34097cda24 | ||
|
|
0ce3703e30 | ||
|
|
464e843186 | ||
|
|
5d2b7a6e0b | ||
|
|
914a868fbd | ||
|
|
03e0c7c71c | ||
|
|
32ffd006fa | ||
|
|
58b85b2e0e | ||
|
|
61653a517d | ||
|
|
2a7bc0e55c | ||
|
|
95b439fbd5 | ||
|
|
1872481f47 | ||
|
|
44b6d23e0f | ||
|
|
58eb0ec52a | ||
|
|
febe16d700 | ||
|
|
8c56091af7 | ||
|
|
eacdce9ed9 | ||
|
|
42c99b0ccb | ||
|
|
2a6c0cfc17 | ||
|
|
84040892df | ||
|
|
345008c673 | ||
|
|
14d1494cd2 | ||
|
|
f1d11e77ed | ||
|
|
b1b8715f7d | ||
|
|
b6d559da1f | ||
|
|
475c412ae4 | ||
|
|
c04a002c55 | ||
|
|
5013a82655 | ||
|
|
05181bf232 | ||
|
|
c22a73e1d0 | ||
|
|
c9b353f7a7 | ||
|
|
64cfc4ff02 | ||
|
|
bb46009efa | ||
|
|
3f9250415f | ||
|
|
c294a534d0 | ||
|
|
85d6970df8 | ||
|
|
260a619a40 | ||
|
|
0c6ef3b7f9 | ||
|
|
0c47434aad | ||
|
|
1d8a1df2c4 | ||
|
|
65b85ec6c0 | ||
|
|
b6b9da7e6e | ||
|
|
d18f2684fb | ||
|
|
e93b079ef4 | ||
|
|
356ad6e468 | ||
|
|
8f35212dd6 | ||
|
|
0827a26642 | ||
|
|
b4756e6dda | ||
|
|
4cc192e445 | ||
|
|
962e5315ab | ||
|
|
2c7e895105 | ||
|
|
00019b9ff0 | ||
|
|
8e776b4dc0 | ||
|
|
ce13b0989d | ||
|
|
c81735cc84 | ||
|
|
5d18759146 | ||
|
|
9cdcfae8f3 | ||
|
|
547d93f631 | ||
|
|
d841ddc50b | ||
|
|
40b5824230 | ||
|
|
86f3e2455d | ||
|
|
9a065cc536 | ||
|
|
8e4dbcaf21 | ||
|
|
6863d2e0af | ||
|
|
c23809488b | ||
|
|
248f5c0209 | ||
|
|
e5aa40fa5d | ||
|
|
1f573b46a4 | ||
|
|
0647bb7f6b | ||
|
|
a73fbbaf7a | ||
|
|
755f5b61b7 | ||
|
|
6869c7401e | ||
|
|
835577b2bc | ||
|
|
859d0d5ad6 | ||
|
|
aed797f438 | ||
|
|
eb8093934f | ||
|
|
608b482906 | ||
|
|
7207c2cca1 | ||
|
|
ed1d0b4197 | ||
|
|
63461e9007 | ||
|
|
40a2145558 | ||
|
|
d883b18751 | ||
|
|
b8e462cf5b | ||
|
|
11df7becd3 | ||
|
|
99f5db8c02 | ||
|
|
19b08a975a | ||
|
|
123f4acfc1 | ||
|
|
0f90426023 |
23
.coveragerc
23
.coveragerc
@@ -40,9 +40,6 @@ omit =
|
||||
homeassistant/components/isy994.py
|
||||
homeassistant/components/*/isy994.py
|
||||
|
||||
homeassistant/components/litejet.py
|
||||
homeassistant/components/*/litejet.py
|
||||
|
||||
homeassistant/components/modbus.py
|
||||
homeassistant/components/*/modbus.py
|
||||
|
||||
@@ -125,8 +122,11 @@ omit =
|
||||
homeassistant/components/alarm_control_panel/simplisafe.py
|
||||
homeassistant/components/binary_sensor/arest.py
|
||||
homeassistant/components/binary_sensor/concord232.py
|
||||
homeassistant/components/binary_sensor/flic.py
|
||||
homeassistant/components/binary_sensor/hikvision.py
|
||||
homeassistant/components/binary_sensor/rest.py
|
||||
homeassistant/components/browser.py
|
||||
homeassistant/components/camera/amcrest.py
|
||||
homeassistant/components/camera/bloomsky.py
|
||||
homeassistant/components/camera/foscam.py
|
||||
homeassistant/components/camera/mjpeg.py
|
||||
@@ -152,6 +152,7 @@ omit =
|
||||
homeassistant/components/device_tracker/bt_home_hub_5.py
|
||||
homeassistant/components/device_tracker/cisco_ios.py
|
||||
homeassistant/components/device_tracker/fritz.py
|
||||
homeassistant/components/device_tracker/gpslogger.py
|
||||
homeassistant/components/device_tracker/icloud.py
|
||||
homeassistant/components/device_tracker/luci.py
|
||||
homeassistant/components/device_tracker/netgear.py
|
||||
@@ -166,6 +167,7 @@ omit =
|
||||
homeassistant/components/discovery.py
|
||||
homeassistant/components/downloader.py
|
||||
homeassistant/components/emoncms_history.py
|
||||
homeassistant/components/emulated_hue/upnp.py
|
||||
homeassistant/components/fan/mqtt.py
|
||||
homeassistant/components/feedreader.py
|
||||
homeassistant/components/foursquare.py
|
||||
@@ -184,11 +186,14 @@ omit =
|
||||
homeassistant/components/light/x10.py
|
||||
homeassistant/components/light/yeelight.py
|
||||
homeassistant/components/lirc.py
|
||||
homeassistant/components/media_player/aquostv.py
|
||||
homeassistant/components/media_player/braviatv.py
|
||||
homeassistant/components/media_player/cast.py
|
||||
homeassistant/components/media_player/cmus.py
|
||||
homeassistant/components/media_player/denon.py
|
||||
homeassistant/components/media_player/denonavr.py
|
||||
homeassistant/components/media_player/directv.py
|
||||
homeassistant/components/media_player/dunehd.py
|
||||
homeassistant/components/media_player/emby.py
|
||||
homeassistant/components/media_player/firetv.py
|
||||
homeassistant/components/media_player/gpmdp.py
|
||||
@@ -209,6 +214,7 @@ omit =
|
||||
homeassistant/components/media_player/snapcast.py
|
||||
homeassistant/components/media_player/sonos.py
|
||||
homeassistant/components/media_player/squeezebox.py
|
||||
homeassistant/components/media_player/vlc.py
|
||||
homeassistant/components/media_player/yamaha.py
|
||||
homeassistant/components/notify/aws_lambda.py
|
||||
homeassistant/components/notify/aws_sns.py
|
||||
@@ -240,12 +246,14 @@ omit =
|
||||
homeassistant/components/notify/xmpp.py
|
||||
homeassistant/components/nuimo_controller.py
|
||||
homeassistant/components/openalpr.py
|
||||
homeassistant/components/remote/harmony.py
|
||||
homeassistant/components/scene/hunterdouglas_powerview.py
|
||||
homeassistant/components/sensor/arest.py
|
||||
homeassistant/components/sensor/arwn.py
|
||||
homeassistant/components/sensor/bbox.py
|
||||
homeassistant/components/sensor/bitcoin.py
|
||||
homeassistant/components/sensor/bom.py
|
||||
homeassistant/components/sensor/broadlink.py
|
||||
homeassistant/components/sensor/coinmarketcap.py
|
||||
homeassistant/components/sensor/cpuspeed.py
|
||||
homeassistant/components/sensor/cups.py
|
||||
@@ -278,7 +286,9 @@ omit =
|
||||
homeassistant/components/sensor/mhz19.py
|
||||
homeassistant/components/sensor/miflora.py
|
||||
homeassistant/components/sensor/mqtt_room.py
|
||||
homeassistant/components/sensor/netdata.py
|
||||
homeassistant/components/sensor/neurio_energy.py
|
||||
homeassistant/components/sensor/nut.py
|
||||
homeassistant/components/sensor/nzbget.py
|
||||
homeassistant/components/sensor/ohmconnect.py
|
||||
homeassistant/components/sensor/onewire.py
|
||||
@@ -289,8 +299,10 @@ omit =
|
||||
homeassistant/components/sensor/pvoutput.py
|
||||
homeassistant/components/sensor/sabnzbd.py
|
||||
homeassistant/components/sensor/scrape.py
|
||||
homeassistant/components/sensor/sensehat.py
|
||||
homeassistant/components/sensor/serial_pm.py
|
||||
homeassistant/components/sensor/snmp.py
|
||||
homeassistant/components/sensor/sonarr.py
|
||||
homeassistant/components/sensor/speedtest.py
|
||||
homeassistant/components/sensor/steam_online.py
|
||||
homeassistant/components/sensor/supervisord.py
|
||||
@@ -306,14 +318,19 @@ omit =
|
||||
homeassistant/components/sensor/twitch.py
|
||||
homeassistant/components/sensor/uber.py
|
||||
homeassistant/components/sensor/vasttrafik.py
|
||||
homeassistant/components/sensor/waqi.py
|
||||
homeassistant/components/sensor/xbox_live.py
|
||||
homeassistant/components/sensor/yweather.py
|
||||
homeassistant/components/sensor/zamg.py
|
||||
homeassistant/components/switch/acer_projector.py
|
||||
homeassistant/components/switch/anel_pwrctrl.py
|
||||
homeassistant/components/switch/arest.py
|
||||
homeassistant/components/switch/broadlink.py
|
||||
homeassistant/components/switch/digitalloggers.py
|
||||
homeassistant/components/switch/dlink.py
|
||||
homeassistant/components/switch/edimax.py
|
||||
homeassistant/components/switch/hikvisioncam.py
|
||||
homeassistant/components/switch/hook.py
|
||||
homeassistant/components/switch/mystrom.py
|
||||
homeassistant/components/switch/netio.py
|
||||
homeassistant/components/switch/orvibo.py
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,6 +62,7 @@ pip-log.txt
|
||||
.coverage
|
||||
.tox
|
||||
nosetests.xml
|
||||
htmlcov/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Contributing to Home Assistant
|
||||
|
||||
Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spent a couple of hours and help to integrate them?
|
||||
Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spent a couple of hours and help to integrate them?
|
||||
|
||||
The process is straight-forward.
|
||||
|
||||
- Read [How to get faster PR reviews](https://github.com/kubernetes/kubernetes/blob/master/docs/devel/faster_reviews.md) by Kubernetes (but skip step 0)
|
||||
- Fork the Home Assistant [git repository](https://github.com/home-assistant/home-assistant).
|
||||
- Write the code for your device, notification service, sensor, or IoT thing.
|
||||
- Ensure tests work.
|
||||
- Create a Pull Request against the [**dev**](https://github.com/home-assistant/home-assistant/tree/dev) branch of Home Assistant.
|
||||
|
||||
Still interested? Then you should take a peak at the [developer documentation](https://home-assistant.io/developers/) to get more details.
|
||||
Still interested? Then you should take a peak at the [developer documentation](https://home-assistant.io/developers/) to get more details.
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ WORKDIR /usr/src/app
|
||||
|
||||
RUN pip3 install --no-cache-dir colorlog cython
|
||||
|
||||
# For the nmap tracker, bluetooth tracker, Z-Wave
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends nmap net-tools cython3 libudev-dev sudo libglib2.0-dev bluetooth libbluetooth-dev && \
|
||||
# For the nmap tracker, bluetooth tracker, Z-Wave, tellstick
|
||||
RUN echo "deb http://download.telldus.com/debian/ stable main" >> /etc/apt/sources.list.d/telldus.list && \
|
||||
wget -qO - http://download.telldus.se/debian/telldus-public.key | apt-key add - && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends nmap net-tools cython3 libudev-dev sudo libglib2.0-dev bluetooth libbluetooth-dev \
|
||||
libtelldus-core2 && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
COPY script/build_python_openzwave script/build_python_openzwave
|
||||
|
||||
@@ -20,6 +20,7 @@ import homeassistant.loader as loader
|
||||
import homeassistant.util.package as pkg_util
|
||||
from homeassistant.util.async import (
|
||||
run_coroutine_threadsafe, run_callback_threadsafe)
|
||||
from homeassistant.util.logging import AsyncHandler
|
||||
from homeassistant.util.yaml import clear_secret_cache
|
||||
from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -365,6 +366,7 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
Dynamically loads required components and its dependencies.
|
||||
This method is a coroutine.
|
||||
"""
|
||||
hass.async_track_tasks()
|
||||
setup_lock = hass.data.get('setup_lock')
|
||||
if setup_lock is None:
|
||||
setup_lock = hass.data['setup_lock'] = asyncio.Lock(loop=hass.loop)
|
||||
@@ -427,6 +429,8 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
|
||||
setup_lock.release()
|
||||
|
||||
yield from hass.async_stop_track_tasks()
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
@@ -525,6 +529,10 @@ def enable_logging(hass: core.HomeAssistant, verbose: bool=False,
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# AsyncHandler allready exists?
|
||||
if hass.data.get(core.DATA_ASYNCHANDLER):
|
||||
return
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
err_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
err_path_exists = os.path.isfile(err_log_path)
|
||||
@@ -545,8 +553,12 @@ def enable_logging(hass: core.HomeAssistant, verbose: bool=False,
|
||||
err_handler.setFormatter(
|
||||
logging.Formatter('%(asctime)s %(name)s: %(message)s',
|
||||
datefmt='%y-%m-%d %H:%M:%S'))
|
||||
|
||||
async_handler = AsyncHandler(hass.loop, err_handler)
|
||||
hass.data[core.DATA_ASYNCHANDLER] = async_handler
|
||||
|
||||
logger = logging.getLogger('')
|
||||
logger.addHandler(err_handler)
|
||||
logger.addHandler(async_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
else:
|
||||
|
||||
@@ -4,6 +4,7 @@ Component to interface with an alarm control panel.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -42,36 +43,6 @@ ALARM_SERVICE_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Track states and offer events for sensors."""
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
component.setup(config)
|
||||
|
||||
def alarm_service_handler(service):
|
||||
"""Map services to methods on Alarm."""
|
||||
target_alarms = component.extract_from_service(service)
|
||||
|
||||
code = service.data.get(ATTR_CODE)
|
||||
|
||||
method = SERVICE_TO_METHOD[service.service]
|
||||
|
||||
for alarm in target_alarms:
|
||||
getattr(alarm, method)(code)
|
||||
if alarm.should_poll:
|
||||
alarm.update_ha_state(True)
|
||||
|
||||
descriptions = load_yaml_config_file(
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
for service in SERVICE_TO_METHOD:
|
||||
hass.services.register(DOMAIN, service, alarm_service_handler,
|
||||
descriptions.get(service),
|
||||
schema=ALARM_SERVICE_SCHEMA)
|
||||
return True
|
||||
|
||||
|
||||
def alarm_disarm(hass, code=None, entity_id=None):
|
||||
"""Send the alarm the command for disarm."""
|
||||
data = {}
|
||||
@@ -116,6 +87,53 @@ def alarm_trigger(hass, code=None, entity_id=None):
|
||||
hass.services.call(DOMAIN, SERVICE_ALARM_TRIGGER, data)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Track states and offer events for sensors."""
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
yield from component.async_setup(config)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_service_handler(service):
|
||||
"""Map services to methods on Alarm."""
|
||||
target_alarms = component.async_extract_from_service(service)
|
||||
|
||||
code = service.data.get(ATTR_CODE)
|
||||
|
||||
method = "async_{}".format(SERVICE_TO_METHOD[service.service])
|
||||
|
||||
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.loop.create_task(
|
||||
alarm.async_update_ha_state(True))
|
||||
if hasattr(alarm, 'async_update'):
|
||||
update_tasks.append(hass.loop.create_task(update_coro))
|
||||
else:
|
||||
yield from update_coro
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
descriptions = yield from hass.loop.run_in_executor(
|
||||
None, load_yaml_config_file, os.path.join(
|
||||
os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
for service in SERVICE_TO_METHOD:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_alarm_service_handler,
|
||||
descriptions.get(service), schema=ALARM_SERVICE_SCHEMA)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
class AlarmControlPanel(Entity):
|
||||
"""An abstract class for alarm control devices."""
|
||||
@@ -134,18 +152,42 @@ class AlarmControlPanel(Entity):
|
||||
"""Send disarm command."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
None, self.alarm_disarm, code)
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
None, self.alarm_arm_home, code)
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
None, self.alarm_arm_away, code)
|
||||
|
||||
def alarm_trigger(self, code=None):
|
||||
"""Send alarm trigger command."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_trigger(self, code=None):
|
||||
"""Send alarm trigger command."""
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
None, self.alarm_trigger, code)
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
|
||||
@@ -56,11 +56,6 @@ class AlarmDotCom(alarm.AlarmControlPanel):
|
||||
self._password = password
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return True
|
||||
|
||||
def update(self):
|
||||
"""Fetch the latest state."""
|
||||
self._state = self._alarm.state
|
||||
|
||||
@@ -71,11 +71,6 @@ class Concord232Alarm(alarm.AlarmControlPanel):
|
||||
self._alarm.last_partition_update = datetime.datetime.now()
|
||||
self.update()
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Polling needed."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
@@ -126,7 +121,3 @@ class Concord232Alarm(alarm.AlarmControlPanel):
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
self._alarm.arm('auto')
|
||||
|
||||
def alarm_trigger(self, code=None):
|
||||
"""Alarm trigger command."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -97,7 +97,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel):
|
||||
def _update_callback(self, partition):
|
||||
"""Update HA state, if needed."""
|
||||
if partition is None or int(partition) == self._partition_number:
|
||||
self.hass.async_add_job(self.update_ha_state)
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
|
||||
@@ -116,7 +116,7 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.update_ha_state()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
@@ -125,7 +125,7 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
self._state = STATE_ALARM_ARMED_HOME
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.update_ha_state()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
@@ -139,7 +139,7 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
self._state = STATE_ALARM_ARMED_AWAY
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.update_ha_state()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
@@ -151,7 +151,7 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
self._pre_trigger_state = self._state
|
||||
self._state = STATE_ALARM_TRIGGERED
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.update_ha_state()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._trigger_time:
|
||||
track_point_in_time(
|
||||
|
||||
@@ -62,11 +62,6 @@ class NX584Alarm(alarm.AlarmControlPanel):
|
||||
self._alarm.list_zones()
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Polling needed."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
@@ -117,12 +112,8 @@ class NX584Alarm(alarm.AlarmControlPanel):
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
self._alarm.arm('home')
|
||||
self._alarm.arm('stay')
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
self._alarm.arm('auto')
|
||||
|
||||
def alarm_trigger(self, code=None):
|
||||
"""Alarm trigger command."""
|
||||
raise NotImplementedError()
|
||||
self._alarm.arm('exit')
|
||||
|
||||
@@ -61,11 +61,6 @@ class SimpliSafeAlarm(alarm.AlarmControlPanel):
|
||||
else:
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Poll the SimpliSafe API."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
@@ -104,7 +99,6 @@ class SimpliSafeAlarm(alarm.AlarmControlPanel):
|
||||
return
|
||||
self.simplisafe.set_state('off')
|
||||
_LOGGER.info('SimpliSafe alarm disarming')
|
||||
self.update()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
@@ -112,7 +106,6 @@ class SimpliSafeAlarm(alarm.AlarmControlPanel):
|
||||
return
|
||||
self.simplisafe.set_state('home')
|
||||
_LOGGER.info('SimpliSafe alarm arming home')
|
||||
self.update()
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
@@ -120,7 +113,6 @@ class SimpliSafeAlarm(alarm.AlarmControlPanel):
|
||||
return
|
||||
self.simplisafe.set_state('away')
|
||||
_LOGGER.info('SimpliSafe alarm arming away')
|
||||
self.update()
|
||||
|
||||
def _validate_code(self, code, state):
|
||||
"""Validate given code."""
|
||||
|
||||
@@ -84,18 +84,15 @@ class VerisureAlarm(alarm.AlarmControlPanel):
|
||||
hub.my_pages.alarm.set(code, 'DISARMED')
|
||||
_LOGGER.info('verisure alarm disarming')
|
||||
hub.my_pages.alarm.wait_while_pending()
|
||||
self.update()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
hub.my_pages.alarm.set(code, 'ARMED_HOME')
|
||||
_LOGGER.info('verisure alarm arming home')
|
||||
hub.my_pages.alarm.wait_while_pending()
|
||||
self.update()
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
hub.my_pages.alarm.set(code, 'ARMED_AWAY')
|
||||
_LOGGER.info('verisure alarm arming away')
|
||||
hub.my_pages.alarm.wait_while_pending()
|
||||
self.update()
|
||||
|
||||
@@ -118,7 +118,7 @@ class AlexaIntentsView(HomeAssistantView):
|
||||
|
||||
def __init__(self, hass, intents):
|
||||
"""Initialize Alexa view."""
|
||||
super().__init__(hass)
|
||||
super().__init__()
|
||||
|
||||
intents = copy.deepcopy(intents)
|
||||
template.attach(hass, intents)
|
||||
@@ -150,7 +150,7 @@ class AlexaIntentsView(HomeAssistantView):
|
||||
return None
|
||||
|
||||
intent = req.get('intent')
|
||||
response = AlexaResponse(self.hass, intent)
|
||||
response = AlexaResponse(request.app['hass'], intent)
|
||||
|
||||
if req_type == 'LaunchRequest':
|
||||
response.add_speech(
|
||||
@@ -282,7 +282,7 @@ class AlexaFlashBriefingView(HomeAssistantView):
|
||||
|
||||
def __init__(self, hass, flash_briefings):
|
||||
"""Initialize Alexa view."""
|
||||
super().__init__(hass)
|
||||
super().__init__()
|
||||
self.flash_briefings = copy.deepcopy(flash_briefings)
|
||||
template.attach(hass, self.flash_briefings)
|
||||
|
||||
|
||||
@@ -77,8 +77,10 @@ class APIEventStream(HomeAssistantView):
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
"""Provide a streaming interface for the event bus."""
|
||||
# pylint: disable=no-self-use
|
||||
hass = request.app['hass']
|
||||
stop_obj = object()
|
||||
to_write = asyncio.Queue(loop=self.hass.loop)
|
||||
to_write = asyncio.Queue(loop=hass.loop)
|
||||
|
||||
restrict = request.GET.get('restrict')
|
||||
if restrict:
|
||||
@@ -106,7 +108,7 @@ class APIEventStream(HomeAssistantView):
|
||||
response.content_type = 'text/event-stream'
|
||||
yield from response.prepare(request)
|
||||
|
||||
unsub_stream = self.hass.bus.async_listen(MATCH_ALL, forward_events)
|
||||
unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events)
|
||||
|
||||
try:
|
||||
_LOGGER.debug('STREAM %s ATTACHED', id(stop_obj))
|
||||
@@ -117,7 +119,7 @@ class APIEventStream(HomeAssistantView):
|
||||
while True:
|
||||
try:
|
||||
with async_timeout.timeout(STREAM_PING_INTERVAL,
|
||||
loop=self.hass.loop):
|
||||
loop=hass.loop):
|
||||
payload = yield from to_write.get()
|
||||
|
||||
if payload is stop_obj:
|
||||
@@ -145,7 +147,7 @@ class APIConfigView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get current configuration."""
|
||||
return self.json(self.hass.config.as_dict())
|
||||
return self.json(request.app['hass'].config.as_dict())
|
||||
|
||||
|
||||
class APIDiscoveryView(HomeAssistantView):
|
||||
@@ -158,10 +160,11 @@ class APIDiscoveryView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get discovery info."""
|
||||
needs_auth = self.hass.config.api.api_password is not None
|
||||
hass = request.app['hass']
|
||||
needs_auth = hass.config.api.api_password is not None
|
||||
return self.json({
|
||||
'base_url': self.hass.config.api.base_url,
|
||||
'location_name': self.hass.config.location_name,
|
||||
'base_url': hass.config.api.base_url,
|
||||
'location_name': hass.config.location_name,
|
||||
'requires_api_password': needs_auth,
|
||||
'version': __version__
|
||||
})
|
||||
@@ -176,7 +179,7 @@ class APIStatesView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get current states."""
|
||||
return self.json(self.hass.states.async_all())
|
||||
return self.json(request.app['hass'].states.async_all())
|
||||
|
||||
|
||||
class APIEntityStateView(HomeAssistantView):
|
||||
@@ -188,7 +191,7 @@ class APIEntityStateView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request, entity_id):
|
||||
"""Retrieve state of entity."""
|
||||
state = self.hass.states.get(entity_id)
|
||||
state = request.app['hass'].states.get(entity_id)
|
||||
if state:
|
||||
return self.json(state)
|
||||
else:
|
||||
@@ -197,6 +200,7 @@ class APIEntityStateView(HomeAssistantView):
|
||||
@asyncio.coroutine
|
||||
def post(self, request, entity_id):
|
||||
"""Update state of entity."""
|
||||
hass = request.app['hass']
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
@@ -211,15 +215,14 @@ class APIEntityStateView(HomeAssistantView):
|
||||
attributes = data.get('attributes')
|
||||
force_update = data.get('force_update', False)
|
||||
|
||||
is_new_state = self.hass.states.get(entity_id) is None
|
||||
is_new_state = hass.states.get(entity_id) is None
|
||||
|
||||
# Write state
|
||||
self.hass.states.async_set(entity_id, new_state, attributes,
|
||||
force_update)
|
||||
hass.states.async_set(entity_id, new_state, attributes, force_update)
|
||||
|
||||
# Read the state back for our response
|
||||
status_code = HTTP_CREATED if is_new_state else 200
|
||||
resp = self.json(self.hass.states.get(entity_id), status_code)
|
||||
resp = self.json(hass.states.get(entity_id), status_code)
|
||||
|
||||
resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id))
|
||||
|
||||
@@ -228,7 +231,7 @@ class APIEntityStateView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def delete(self, request, entity_id):
|
||||
"""Remove entity."""
|
||||
if self.hass.states.async_remove(entity_id):
|
||||
if request.app['hass'].states.async_remove(entity_id):
|
||||
return self.json_message('Entity removed')
|
||||
else:
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
@@ -243,7 +246,7 @@ class APIEventListenersView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get event listeners."""
|
||||
return self.json(async_events_json(self.hass))
|
||||
return self.json(async_events_json(request.app['hass']))
|
||||
|
||||
|
||||
class APIEventView(HomeAssistantView):
|
||||
@@ -271,7 +274,8 @@ class APIEventView(HomeAssistantView):
|
||||
if state:
|
||||
event_data[key] = state
|
||||
|
||||
self.hass.bus.async_fire(event_type, event_data, ha.EventOrigin.remote)
|
||||
request.app['hass'].bus.async_fire(event_type, event_data,
|
||||
ha.EventOrigin.remote)
|
||||
|
||||
return self.json_message("Event {} fired.".format(event_type))
|
||||
|
||||
@@ -285,7 +289,7 @@ class APIServicesView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get registered services."""
|
||||
return self.json(async_services_json(self.hass))
|
||||
return self.json(async_services_json(request.app['hass']))
|
||||
|
||||
|
||||
class APIDomainServicesView(HomeAssistantView):
|
||||
@@ -300,12 +304,12 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
|
||||
Returns a list of changed states.
|
||||
"""
|
||||
hass = request.app['hass']
|
||||
body = yield from request.text()
|
||||
data = json.loads(body) if body else None
|
||||
|
||||
with AsyncTrackStates(self.hass) as changed_states:
|
||||
yield from self.hass.services.async_call(domain, service, data,
|
||||
True)
|
||||
with AsyncTrackStates(hass) as changed_states:
|
||||
yield from hass.services.async_call(domain, service, data, True)
|
||||
|
||||
return self.json(changed_states)
|
||||
|
||||
@@ -320,6 +324,7 @@ class APIEventForwardingView(HomeAssistantView):
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Setup an event forwarder."""
|
||||
hass = request.app['hass']
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
@@ -340,14 +345,14 @@ class APIEventForwardingView(HomeAssistantView):
|
||||
|
||||
api = rem.API(host, api_password, port)
|
||||
|
||||
valid = yield from self.hass.loop.run_in_executor(
|
||||
valid = yield from hass.loop.run_in_executor(
|
||||
None, api.validate_api)
|
||||
if not valid:
|
||||
return self.json_message("Unable to validate API.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
if self.event_forwarder is None:
|
||||
self.event_forwarder = rem.EventForwarder(self.hass)
|
||||
self.event_forwarder = rem.EventForwarder(hass)
|
||||
|
||||
self.event_forwarder.async_connect(api)
|
||||
|
||||
@@ -389,7 +394,7 @@ class APIComponentsView(HomeAssistantView):
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get current loaded components."""
|
||||
return self.json(self.hass.config.components)
|
||||
return self.json(request.app['hass'].config.components)
|
||||
|
||||
|
||||
class APIErrorLogView(HomeAssistantView):
|
||||
@@ -402,7 +407,7 @@ class APIErrorLogView(HomeAssistantView):
|
||||
def get(self, request):
|
||||
"""Serve error log."""
|
||||
resp = yield from self.file(
|
||||
request, self.hass.config.path(ERROR_LOG_FILENAME))
|
||||
request, request.app['hass'].config.path(ERROR_LOG_FILENAME))
|
||||
return resp
|
||||
|
||||
|
||||
@@ -417,7 +422,7 @@ class APITemplateView(HomeAssistantView):
|
||||
"""Render a template."""
|
||||
try:
|
||||
data = yield from request.json()
|
||||
tpl = template.Template(data['template'], self.hass)
|
||||
tpl = template.Template(data['template'], request.app['hass'])
|
||||
return tpl.async_render(data.get('variables'))
|
||||
except (ValueError, TemplateError) as ex:
|
||||
return self.json_message('Error rendering template: {}'.format(ex),
|
||||
|
||||
@@ -11,22 +11,34 @@ import voluptuous as vol
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import CONF_PLATFORM
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers.event import track_point_in_utc_time
|
||||
|
||||
DEPENDENCIES = ['litejet']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_NUMBER = 'number'
|
||||
CONF_HELD_MORE_THAN = 'held_more_than'
|
||||
CONF_HELD_LESS_THAN = 'held_less_than'
|
||||
|
||||
TRIGGER_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_PLATFORM): 'litejet',
|
||||
vol.Required(CONF_NUMBER): cv.positive_int
|
||||
vol.Required(CONF_NUMBER): cv.positive_int,
|
||||
vol.Optional(CONF_HELD_MORE_THAN):
|
||||
vol.All(cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional(CONF_HELD_LESS_THAN):
|
||||
vol.All(cv.time_period, cv.positive_timedelta)
|
||||
})
|
||||
|
||||
|
||||
def async_trigger(hass, config, action):
|
||||
"""Listen for events based on configuration."""
|
||||
number = config.get(CONF_NUMBER)
|
||||
held_more_than = config.get(CONF_HELD_MORE_THAN)
|
||||
held_less_than = config.get(CONF_HELD_LESS_THAN)
|
||||
pressed_time = None
|
||||
cancel_pressed_more_than = None
|
||||
|
||||
@callback
|
||||
def call_action():
|
||||
@@ -34,8 +46,53 @@ def async_trigger(hass, config, action):
|
||||
hass.async_run_job(action, {
|
||||
'trigger': {
|
||||
CONF_PLATFORM: 'litejet',
|
||||
CONF_NUMBER: number
|
||||
CONF_NUMBER: number,
|
||||
CONF_HELD_MORE_THAN: held_more_than,
|
||||
CONF_HELD_LESS_THAN: held_less_than
|
||||
},
|
||||
})
|
||||
|
||||
hass.data['litejet_system'].on_switch_released(number, call_action)
|
||||
# held_more_than and held_less_than: trigger on released (if in time range)
|
||||
# held_more_than: trigger after pressed with calculation
|
||||
# held_less_than: trigger on released with calculation
|
||||
# neither: trigger on pressed
|
||||
|
||||
@callback
|
||||
def pressed_more_than_satisfied(now):
|
||||
"""Handle the LiteJet's switch's button pressed >= held_more_than."""
|
||||
call_action()
|
||||
|
||||
def pressed():
|
||||
"""Handle the press of the LiteJet switch's button."""
|
||||
nonlocal cancel_pressed_more_than, pressed_time
|
||||
nonlocal held_less_than, held_more_than
|
||||
pressed_time = dt_util.utcnow()
|
||||
if held_more_than is None and held_less_than is None:
|
||||
call_action()
|
||||
if held_more_than is not None and held_less_than is None:
|
||||
cancel_pressed_more_than = track_point_in_utc_time(
|
||||
hass,
|
||||
pressed_more_than_satisfied,
|
||||
dt_util.utcnow() + held_more_than)
|
||||
|
||||
def released():
|
||||
"""Handle the release of the LiteJet switch's button."""
|
||||
nonlocal cancel_pressed_more_than, pressed_time
|
||||
nonlocal held_less_than, held_more_than
|
||||
# pylint: disable=not-callable
|
||||
if cancel_pressed_more_than is not None:
|
||||
cancel_pressed_more_than()
|
||||
cancel_pressed_more_than = None
|
||||
held_time = dt_util.utcnow() - pressed_time
|
||||
if held_less_than is not None and held_time < held_less_than:
|
||||
if held_more_than is None or held_time > held_more_than:
|
||||
call_action()
|
||||
|
||||
hass.data['litejet_system'].on_switch_pressed(number, pressed)
|
||||
hass.data['litejet_system'].on_switch_released(number, released)
|
||||
|
||||
def async_remove():
|
||||
"""Remove all subscriptions used for this trigger."""
|
||||
return
|
||||
|
||||
return async_remove
|
||||
|
||||
@@ -4,6 +4,8 @@ Offer MQTT listening automation rules.
|
||||
For more details about this automation rule, please refer to the documentation
|
||||
at https://home-assistant.io/components/automation/#mqtt-trigger
|
||||
"""
|
||||
import json
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
@@ -31,13 +33,20 @@ def async_trigger(hass, config, action):
|
||||
def mqtt_automation_listener(msg_topic, msg_payload, qos):
|
||||
"""Listen for MQTT messages."""
|
||||
if payload is None or payload == msg_payload:
|
||||
data = {
|
||||
'platform': 'mqtt',
|
||||
'topic': msg_topic,
|
||||
'payload': msg_payload,
|
||||
'qos': qos,
|
||||
}
|
||||
|
||||
try:
|
||||
data['payload_json'] = json.loads(msg_payload)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
hass.async_run_job(action, {
|
||||
'trigger': {
|
||||
'platform': 'mqtt',
|
||||
'topic': msg_topic,
|
||||
'payload': msg_payload,
|
||||
'qos': qos,
|
||||
}
|
||||
'trigger': data
|
||||
})
|
||||
|
||||
return mqtt.async_subscribe(hass, topic, mqtt_automation_listener)
|
||||
|
||||
@@ -64,10 +64,19 @@ def async_trigger(hass, config, action):
|
||||
call_action()
|
||||
return
|
||||
|
||||
@callback
|
||||
def clear_listener():
|
||||
"""Clear all unsub listener."""
|
||||
nonlocal async_remove_state_for_cancel
|
||||
nonlocal async_remove_state_for_listener
|
||||
async_remove_state_for_listener = None
|
||||
async_remove_state_for_cancel = None
|
||||
|
||||
@callback
|
||||
def state_for_listener(now):
|
||||
"""Fire on state changes after a delay and calls action."""
|
||||
async_remove_state_for_cancel()
|
||||
clear_listener()
|
||||
call_action()
|
||||
|
||||
@callback
|
||||
@@ -77,6 +86,7 @@ def async_trigger(hass, config, action):
|
||||
return
|
||||
async_remove_state_for_listener()
|
||||
async_remove_state_for_cancel()
|
||||
clear_listener()
|
||||
|
||||
async_remove_state_for_listener = async_track_point_in_utc_time(
|
||||
hass, state_for_listener, dt_util.utcnow() + time_delta)
|
||||
|
||||
@@ -4,6 +4,7 @@ Component to interface with binary sensors.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -39,13 +40,13 @@ SENSOR_CLASSES = [
|
||||
SENSOR_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(SENSOR_CLASSES))
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Track states and offer events for binary sensors."""
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
component.setup(config)
|
||||
|
||||
yield from component.async_setup(config)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.command_line/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -23,7 +22,7 @@ DEFAULT_NAME = 'Binary Command Sensor'
|
||||
DEFAULT_PAYLOAD_ON = 'ON'
|
||||
DEFAULT_PAYLOAD_OFF = 'OFF'
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
|
||||
SCAN_INTERVAL = 60
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_COMMAND): cv.string,
|
||||
|
||||
@@ -20,7 +20,7 @@ DEPENDENCIES = ['enocean']
|
||||
DEFAULT_NAME = 'EnOcean binary sensor'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_ID): cv.string,
|
||||
vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_SENSOR_CLASS, default=None): SENSOR_CLASSES_SCHEMA,
|
||||
})
|
||||
|
||||
257
homeassistant/components/binary_sensor/flic.py
Normal file
257
homeassistant/components/binary_sensor/flic.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Contains functionality to use flic buttons as a binary sensor."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PORT, CONF_DISCOVERY, CONF_TIMEOUT,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.util.async import run_callback_threadsafe
|
||||
|
||||
|
||||
REQUIREMENTS = ['https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TIMEOUT = 3
|
||||
|
||||
CLICK_TYPE_SINGLE = "single"
|
||||
CLICK_TYPE_DOUBLE = "double"
|
||||
CLICK_TYPE_HOLD = "hold"
|
||||
CLICK_TYPES = [CLICK_TYPE_SINGLE, CLICK_TYPE_DOUBLE, CLICK_TYPE_HOLD]
|
||||
|
||||
CONF_IGNORED_CLICK_TYPES = "ignored_click_types"
|
||||
|
||||
EVENT_NAME = "flic_click"
|
||||
EVENT_DATA_NAME = "button_name"
|
||||
EVENT_DATA_ADDRESS = "button_address"
|
||||
EVENT_DATA_TYPE = "click_type"
|
||||
EVENT_DATA_QUEUED_TIME = "queued_time"
|
||||
|
||||
# Validation of the user's configuration
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_HOST, default='localhost'): cv.string,
|
||||
vol.Optional(CONF_PORT, default=5551): cv.port,
|
||||
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
|
||||
vol.Optional(CONF_IGNORED_CLICK_TYPES): vol.All(cv.ensure_list,
|
||||
[vol.In(CLICK_TYPES)])
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Setup the flic platform."""
|
||||
import pyflic
|
||||
|
||||
# Initialize flic client responsible for
|
||||
# connecting to buttons and retrieving events
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
discovery = config.get(CONF_DISCOVERY)
|
||||
|
||||
try:
|
||||
client = pyflic.FlicClient(host, port)
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.error("Failed to connect to flic server.")
|
||||
return
|
||||
|
||||
def new_button_callback(address):
|
||||
"""Setup newly verified button as device in home assistant."""
|
||||
hass.add_job(async_setup_button(hass, config, async_add_entities,
|
||||
client, address))
|
||||
|
||||
client.on_new_verified_button = new_button_callback
|
||||
if discovery:
|
||||
start_scanning(hass, config, async_add_entities, client)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
|
||||
lambda event: client.close())
|
||||
hass.loop.run_in_executor(None, client.handle_events)
|
||||
|
||||
# Get addresses of already verified buttons
|
||||
addresses = yield from async_get_verified_addresses(client)
|
||||
if addresses:
|
||||
for address in addresses:
|
||||
yield from async_setup_button(hass, config, async_add_entities,
|
||||
client, address)
|
||||
|
||||
|
||||
def start_scanning(hass, config, async_add_entities, client):
|
||||
"""Start a new flic client for scanning & connceting to new buttons."""
|
||||
import pyflic
|
||||
|
||||
scan_wizard = pyflic.ScanWizard()
|
||||
|
||||
def scan_completed_callback(scan_wizard, result, address, name):
|
||||
"""Restart scan wizard to constantly check for new buttons."""
|
||||
if result == pyflic.ScanWizardResult.WizardSuccess:
|
||||
_LOGGER.info("Found new button (%s)", address)
|
||||
elif result != pyflic.ScanWizardResult.WizardFailedTimeout:
|
||||
_LOGGER.warning("Failed to connect to button (%s). Reason: %s",
|
||||
address, result)
|
||||
|
||||
# Restart scan wizard
|
||||
start_scanning(hass, config, async_add_entities, client)
|
||||
|
||||
scan_wizard.on_completed = scan_completed_callback
|
||||
client.add_scan_wizard(scan_wizard)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_button(hass, config, async_add_entities, client, address):
|
||||
"""Setup single button device."""
|
||||
timeout = config.get(CONF_TIMEOUT)
|
||||
ignored_click_types = config.get(CONF_IGNORED_CLICK_TYPES)
|
||||
button = FlicButton(hass, client, address, timeout, ignored_click_types)
|
||||
_LOGGER.info("Connected to button (%s)", address)
|
||||
|
||||
yield from async_add_entities([button])
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_get_verified_addresses(client):
|
||||
"""Retrieve addresses of verified buttons."""
|
||||
future = asyncio.Future()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def get_info_callback(items):
|
||||
"""Set the addressed of connected buttons as result of the future."""
|
||||
addresses = items["bd_addr_of_verified_buttons"]
|
||||
run_callback_threadsafe(loop, future.set_result, addresses)
|
||||
client.get_info(get_info_callback)
|
||||
|
||||
return future
|
||||
|
||||
|
||||
class FlicButton(BinarySensorDevice):
|
||||
"""Representation of a flic button."""
|
||||
|
||||
def __init__(self, hass, client, address, timeout, ignored_click_types):
|
||||
"""Initialize the flic button."""
|
||||
import pyflic
|
||||
|
||||
self._hass = hass
|
||||
self._address = address
|
||||
self._timeout = timeout
|
||||
self._is_down = False
|
||||
self._ignored_click_types = ignored_click_types or []
|
||||
self._hass_click_types = {
|
||||
pyflic.ClickType.ButtonClick: CLICK_TYPE_SINGLE,
|
||||
pyflic.ClickType.ButtonSingleClick: CLICK_TYPE_SINGLE,
|
||||
pyflic.ClickType.ButtonDoubleClick: CLICK_TYPE_DOUBLE,
|
||||
pyflic.ClickType.ButtonHold: CLICK_TYPE_HOLD,
|
||||
}
|
||||
|
||||
self._channel = self._create_channel()
|
||||
client.add_connection_channel(self._channel)
|
||||
|
||||
def _create_channel(self):
|
||||
"""Create a new connection channel to the button."""
|
||||
import pyflic
|
||||
|
||||
channel = pyflic.ButtonConnectionChannel(self._address)
|
||||
channel.on_button_up_or_down = self._on_up_down
|
||||
|
||||
# If all types of clicks should be ignored, skip registering callbacks
|
||||
if set(self._ignored_click_types) == set(CLICK_TYPES):
|
||||
return channel
|
||||
|
||||
if CLICK_TYPE_DOUBLE in self._ignored_click_types:
|
||||
# Listen to all but double click type events
|
||||
channel.on_button_click_or_hold = self._on_click
|
||||
elif CLICK_TYPE_HOLD in self._ignored_click_types:
|
||||
# Listen to all but hold click type events
|
||||
channel.on_button_single_or_double_click = self._on_click
|
||||
else:
|
||||
# Listen to all click type events
|
||||
channel.on_button_single_or_double_click_or_hold = self._on_click
|
||||
|
||||
return channel
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return "flic_%s" % self.address.replace(":", "")
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
"""Return the bluetooth address of the device."""
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._is_down
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return device specific state attributes."""
|
||||
attr = super(FlicButton, self).state_attributes
|
||||
attr["address"] = self.address
|
||||
|
||||
return attr
|
||||
|
||||
def _queued_event_check(self, click_type, time_diff):
|
||||
"""Generate a log message and returns true if timeout exceeded."""
|
||||
time_string = "{:d} {}".format(
|
||||
time_diff, "second" if time_diff == 1 else "seconds")
|
||||
|
||||
if time_diff > self._timeout:
|
||||
_LOGGER.warning(
|
||||
"Queued %s dropped for %s. Time in queue was %s.",
|
||||
click_type, self.address, time_string)
|
||||
return True
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Queued %s allowed for %s. Time in queue was %s.",
|
||||
click_type, self.address, time_string)
|
||||
return False
|
||||
|
||||
def _on_up_down(self, channel, click_type, was_queued, time_diff):
|
||||
"""Update device state, if event was not queued."""
|
||||
import pyflic
|
||||
|
||||
if was_queued and self._queued_event_check(click_type, time_diff):
|
||||
return
|
||||
|
||||
self._is_down = click_type == pyflic.ClickType.ButtonDown
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def _on_click(self, channel, click_type, was_queued, time_diff):
|
||||
"""Fire click event, if event was not queued."""
|
||||
# Return if click event was queued beyond allowed timeout
|
||||
if was_queued and self._queued_event_check(click_type, time_diff):
|
||||
return
|
||||
|
||||
# Return if click event is in ignored click types
|
||||
hass_click_type = self._hass_click_types[click_type]
|
||||
if hass_click_type in self._ignored_click_types:
|
||||
return
|
||||
|
||||
self._hass.bus.fire(EVENT_NAME, {
|
||||
EVENT_DATA_NAME: self.name,
|
||||
EVENT_DATA_ADDRESS: self.address,
|
||||
EVENT_DATA_QUEUED_TIME: time_diff,
|
||||
EVENT_DATA_TYPE: hass_click_type
|
||||
})
|
||||
|
||||
def _connection_status_changed(self, channel,
|
||||
connection_status, disconnect_reason):
|
||||
"""Remove device, if button disconnects."""
|
||||
import pyflic
|
||||
|
||||
if connection_status == pyflic.ConnectionStatus.Disconnected:
|
||||
_LOGGER.info("Button (%s) disconnected. Reason: %s",
|
||||
self.address, disconnect_reason)
|
||||
self.remove()
|
||||
262
homeassistant/components/binary_sensor/hikvision.py
Normal file
262
homeassistant/components/binary_sensor/hikvision.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Support for Hikvision event stream events represented as binary sensors.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.hikvision/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.helpers.event import track_point_in_utc_time
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_SSL, EVENT_HOMEASSISTANT_STOP, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE)
|
||||
|
||||
REQUIREMENTS = ['pyhik==0.0.6', 'pydispatcher==2.0.5']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_IGNORED = 'ignored'
|
||||
CONF_DELAY = 'delay'
|
||||
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_IGNORED = False
|
||||
DEFAULT_DELAY = 0
|
||||
|
||||
ATTR_DELAY = 'delay'
|
||||
|
||||
SENSOR_CLASS_MAP = {
|
||||
'Motion': 'motion',
|
||||
'Line Crossing': 'motion',
|
||||
'IO Trigger': None,
|
||||
'Field Detection': 'motion',
|
||||
'Video Loss': None,
|
||||
'Tamper Detection': 'motion',
|
||||
'Shelter Alarm': None,
|
||||
'Disk Full': None,
|
||||
'Disk Error': None,
|
||||
'Net Interface Broken': 'connectivity',
|
||||
'IP Conflict': 'connectivity',
|
||||
'Illegal Access': None,
|
||||
'Video Mismatch': None,
|
||||
'Bad Video': None,
|
||||
'PIR Alarm': 'motion',
|
||||
'Face Detection': 'motion',
|
||||
}
|
||||
|
||||
CUSTOMIZE_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_IGNORED, default=DEFAULT_IGNORED): cv.boolean,
|
||||
vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int
|
||||
})
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NAME, default=None): cv.string,
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_SSL, default=False): cv.boolean,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_CUSTOMIZE, default={}):
|
||||
vol.Schema({cv.string: CUSTOMIZE_SCHEMA}),
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Setup Hikvision binary sensor devices."""
|
||||
name = config.get(CONF_NAME)
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
|
||||
customize = config.get(CONF_CUSTOMIZE)
|
||||
|
||||
if config.get(CONF_SSL):
|
||||
protocol = "https"
|
||||
else:
|
||||
protocol = "http"
|
||||
|
||||
url = '{}://{}'.format(protocol, host)
|
||||
|
||||
data = HikvisionData(hass, url, port, name, username, password)
|
||||
|
||||
if data.sensors is None:
|
||||
_LOGGER.error('Hikvision event stream has no data, unable to setup.')
|
||||
return False
|
||||
|
||||
entities = []
|
||||
|
||||
for sensor in data.sensors:
|
||||
# Build sensor name, then parse customize config.
|
||||
sensor_name = sensor.replace(' ', '_')
|
||||
|
||||
custom = customize.get(sensor_name.lower(), {})
|
||||
ignore = custom.get(CONF_IGNORED)
|
||||
delay = custom.get(CONF_DELAY)
|
||||
|
||||
_LOGGER.debug('Entity: %s - %s, Options - Ignore: %s, Delay: %s',
|
||||
data.name, sensor_name, ignore, delay)
|
||||
if not ignore:
|
||||
entities.append(HikvisionBinarySensor(hass, sensor, data, delay))
|
||||
|
||||
add_entities(entities)
|
||||
|
||||
|
||||
class HikvisionData(object):
|
||||
"""Hikvision camera event stream object."""
|
||||
|
||||
def __init__(self, hass, url, port, name, username, password):
|
||||
"""Initialize the data oject."""
|
||||
from pyhik.hikvision import HikCamera
|
||||
self._url = url
|
||||
self._port = port
|
||||
self._name = name
|
||||
self._username = username
|
||||
self._password = password
|
||||
|
||||
# Establish camera
|
||||
self._cam = HikCamera(self._url, self._port,
|
||||
self._username, self._password)
|
||||
|
||||
if self._name is None:
|
||||
self._name = self._cam.get_name
|
||||
|
||||
# Start event stream
|
||||
self._cam.start_stream()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik)
|
||||
|
||||
def stop_hik(self, event):
|
||||
"""Shutdown Hikvision subscriptions and subscription thread on exit."""
|
||||
self._cam.disconnect()
|
||||
|
||||
@property
|
||||
def sensors(self):
|
||||
"""Return list of available sensors and their states."""
|
||||
return self._cam.current_event_states
|
||||
|
||||
@property
|
||||
def cam_id(self):
|
||||
"""Return camera id."""
|
||||
return self._cam.get_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return camera name."""
|
||||
return self._name
|
||||
|
||||
|
||||
class HikvisionBinarySensor(BinarySensorDevice):
|
||||
"""Representation of a Hikvision binary sensor."""
|
||||
|
||||
def __init__(self, hass, sensor, cam, delay):
|
||||
"""Initialize the binary_sensor."""
|
||||
from pydispatch import dispatcher
|
||||
|
||||
self._hass = hass
|
||||
self._cam = cam
|
||||
self._name = self._cam.name + ' ' + sensor
|
||||
self._id = self._cam.cam_id + '.' + sensor
|
||||
self._sensor = sensor
|
||||
|
||||
if delay is None:
|
||||
self._delay = 0
|
||||
else:
|
||||
self._delay = delay
|
||||
|
||||
self._timer = None
|
||||
|
||||
# Form signal for dispatcher
|
||||
signal = 'ValueChanged.{}'.format(self._cam.cam_id)
|
||||
|
||||
dispatcher.connect(self._update_callback,
|
||||
signal=signal,
|
||||
sender=self._sensor)
|
||||
|
||||
def _sensor_state(self):
|
||||
"""Extract sensor state."""
|
||||
return self._cam.sensors[self._sensor][0]
|
||||
|
||||
def _sensor_last_update(self):
|
||||
"""Extract sensor last update time."""
|
||||
return self._cam.sensors[self._sensor][3]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the Hikvision sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return an unique ID."""
|
||||
return '{}.{}'.format(self.__class__, self._id)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._sensor_state()
|
||||
|
||||
@property
|
||||
def sensor_class(self):
|
||||
"""Return the class of this sensor, from SENSOR_CLASSES."""
|
||||
try:
|
||||
return SENSOR_CLASS_MAP[self._sensor]
|
||||
except KeyError:
|
||||
# Sensor must be unknown to us, add as generic
|
||||
return None
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attr = {}
|
||||
attr[ATTR_LAST_TRIP_TIME] = self._sensor_last_update()
|
||||
|
||||
if self._delay != 0:
|
||||
attr[ATTR_DELAY] = self._delay
|
||||
|
||||
return attr
|
||||
|
||||
def _update_callback(self, signal, sender):
|
||||
"""Update the sensor's state, if needed."""
|
||||
_LOGGER.debug('Dispatcher callback, signal: %s, sender: %s',
|
||||
signal, sender)
|
||||
|
||||
if sender is not self._sensor:
|
||||
return
|
||||
|
||||
if self._delay > 0 and not self.is_on:
|
||||
# Set timer to wait until updating the state
|
||||
def _delay_update(now):
|
||||
"""Timer callback for sensor update."""
|
||||
_LOGGER.debug('%s Called delayed (%ssec) update.',
|
||||
self._name, self._delay)
|
||||
self.schedule_update_ha_state()
|
||||
self._timer = None
|
||||
|
||||
if self._timer is not None:
|
||||
self._timer()
|
||||
self._timer = None
|
||||
|
||||
self._timer = track_point_in_utc_time(
|
||||
self._hass, _delay_update,
|
||||
utcnow() + timedelta(seconds=self._delay))
|
||||
|
||||
elif self._delay > 0 and self.is_on:
|
||||
# For delayed sensors kill any callbacks on true events and update
|
||||
if self._timer is not None:
|
||||
self._timer()
|
||||
self._timer = None
|
||||
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
else:
|
||||
self.schedule_update_ha_state()
|
||||
@@ -7,7 +7,8 @@ https://home-assistant.io/components/binary_sensor.homematic/
|
||||
import logging
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
import homeassistant.components.homematic as homematic
|
||||
from homeassistant.components.homematic import HMDevice
|
||||
from homeassistant.loader import get_component
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,14 +33,16 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None):
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
homematic = get_component("homematic")
|
||||
return homematic.setup_hmdevice_discovery_helper(
|
||||
hass,
|
||||
HMBinarySensor,
|
||||
discovery_info,
|
||||
add_callback_devices
|
||||
)
|
||||
|
||||
|
||||
class HMBinarySensor(homematic.HMDevice, BinarySensorDevice):
|
||||
class HMBinarySensor(HMDevice, BinarySensorDevice):
|
||||
"""Representation of a binary Homematic device."""
|
||||
|
||||
@property
|
||||
|
||||
@@ -4,46 +4,100 @@ Support for Nest Thermostat Binary Sensors.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.nest/
|
||||
"""
|
||||
from itertools import chain
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.sensor.nest import NestSensor
|
||||
from homeassistant.const import (CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS)
|
||||
from homeassistant.components.nest import DATA_NEST
|
||||
from homeassistant.components.nest import (
|
||||
DATA_NEST, is_thermostat, is_camera)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DEPENDENCIES = ['nest']
|
||||
BINARY_TYPES = ['fan',
|
||||
'hvac_ac_state',
|
||||
'hvac_aux_heater_state',
|
||||
'hvac_heater_state',
|
||||
'hvac_heat_x2_state',
|
||||
'hvac_heat_x3_state',
|
||||
'hvac_alt_heat_state',
|
||||
'hvac_alt_heat_x2_state',
|
||||
'hvac_emer_heat_state',
|
||||
'online']
|
||||
|
||||
BINARY_TYPES = ['online']
|
||||
|
||||
CLIMATE_BINARY_TYPES = ['fan',
|
||||
'is_using_emergency_heat',
|
||||
'is_locked',
|
||||
'has_leaf']
|
||||
|
||||
CAMERA_BINARY_TYPES = [
|
||||
'motion_detected',
|
||||
'sound_detected',
|
||||
'person_detected']
|
||||
|
||||
_BINARY_TYPES_DEPRECATED = [
|
||||
'hvac_ac_state',
|
||||
'hvac_aux_heater_state',
|
||||
'hvac_heater_state',
|
||||
'hvac_heat_x2_state',
|
||||
'hvac_heat_x3_state',
|
||||
'hvac_alt_heat_state',
|
||||
'hvac_alt_heat_x2_state',
|
||||
'hvac_emer_heat_state']
|
||||
|
||||
_VALID_BINARY_SENSOR_TYPES = BINARY_TYPES + CLIMATE_BINARY_TYPES \
|
||||
+ CAMERA_BINARY_TYPES
|
||||
_VALID_BINARY_SENSOR_TYPES_WITH_DEPRECATED = _VALID_BINARY_SENSOR_TYPES \
|
||||
+ _BINARY_TYPES_DEPRECATED
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_SCAN_INTERVAL):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
vol.Required(CONF_MONITORED_CONDITIONS):
|
||||
vol.All(cv.ensure_list, [vol.In(BINARY_TYPES)]),
|
||||
vol.All(cv.ensure_list,
|
||||
[vol.In(_VALID_BINARY_SENSOR_TYPES_WITH_DEPRECATED)])
|
||||
})
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup Nest binary sensors."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
nest = hass.data[DATA_NEST]
|
||||
conf = config.get(CONF_MONITORED_CONDITIONS, _VALID_BINARY_SENSOR_TYPES)
|
||||
|
||||
all_sensors = []
|
||||
for structure, device in nest.devices():
|
||||
all_sensors.extend(
|
||||
[NestBinarySensor(structure, device, variable)
|
||||
for variable in config[CONF_MONITORED_CONDITIONS]])
|
||||
for variable in conf:
|
||||
if variable in _BINARY_TYPES_DEPRECATED:
|
||||
wstr = (variable + " is no a longer supported "
|
||||
"monitored_conditions. See "
|
||||
"https://home-assistant.io/components/binary_sensor.nest/ "
|
||||
"for valid options, or remove monitored_conditions "
|
||||
"entirely to get a reasonable default")
|
||||
_LOGGER.error(wstr)
|
||||
|
||||
add_devices(all_sensors, True)
|
||||
sensors = []
|
||||
device_chain = chain(nest.devices(),
|
||||
nest.protect_devices(),
|
||||
nest.camera_devices())
|
||||
for structure, device in device_chain:
|
||||
sensors += [NestBinarySensor(structure, device, variable)
|
||||
for variable in conf
|
||||
if variable in BINARY_TYPES]
|
||||
sensors += [NestBinarySensor(structure, device, variable)
|
||||
for variable in conf
|
||||
if variable in CLIMATE_BINARY_TYPES
|
||||
and is_thermostat(device)]
|
||||
|
||||
if is_camera(device):
|
||||
sensors += [NestBinarySensor(structure, device, variable)
|
||||
for variable in conf
|
||||
if variable in CAMERA_BINARY_TYPES]
|
||||
for activity_zone in device.activity_zones:
|
||||
sensors += [NestActivityZoneSensor(structure,
|
||||
device,
|
||||
activity_zone)]
|
||||
|
||||
add_devices(sensors, True)
|
||||
|
||||
|
||||
class NestBinarySensor(NestSensor, BinarySensorDevice):
|
||||
@@ -57,3 +111,21 @@ class NestBinarySensor(NestSensor, BinarySensorDevice):
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
self._state = bool(getattr(self.device, self.variable))
|
||||
|
||||
|
||||
class NestActivityZoneSensor(NestBinarySensor):
|
||||
"""Represents a Nest binary sensor for activity in a zone."""
|
||||
|
||||
def __init__(self, structure, device, zone):
|
||||
"""Initialize the sensor."""
|
||||
super(NestActivityZoneSensor, self).__init__(structure, device, None)
|
||||
self.zone = zone
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the nest, if any."""
|
||||
return "{} {} activity".format(self._name, self.zone.name)
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
self._state = self.device.has_ongoing_motion_in_zone(self.zone.zone_id)
|
||||
|
||||
@@ -23,9 +23,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# These are the available sensors mapped to binary_sensor class
|
||||
SENSOR_TYPES = {
|
||||
"Someone known": "motion",
|
||||
"Someone unknown": "motion",
|
||||
"Motion": "motion",
|
||||
"Someone known": 'occupancy',
|
||||
"Someone unknown": 'motion',
|
||||
"Motion": 'motion',
|
||||
"Tag Vibration": 'vibration',
|
||||
"Tag Open": 'opening',
|
||||
}
|
||||
|
||||
CONF_HOME = 'home'
|
||||
@@ -48,6 +50,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
home = config.get(CONF_HOME, None)
|
||||
timeout = config.get(CONF_TIMEOUT, 15)
|
||||
|
||||
module_name = None
|
||||
|
||||
import lnetatmo
|
||||
try:
|
||||
data = WelcomeData(netatmo.NETATMO_AUTH, home)
|
||||
@@ -64,23 +68,35 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
camera_name not in config[CONF_CAMERAS]:
|
||||
continue
|
||||
for variable in sensors:
|
||||
add_devices([WelcomeBinarySensor(data, camera_name, home, timeout,
|
||||
variable)])
|
||||
if variable in ('Tag Vibration', 'Tag Open'):
|
||||
continue
|
||||
add_devices([WelcomeBinarySensor(data, camera_name, module_name,
|
||||
home, timeout, variable)])
|
||||
|
||||
for module_name in data.get_module_names(camera_name):
|
||||
for variable in sensors:
|
||||
if variable in ('Tag Vibration', 'Tag Open'):
|
||||
add_devices([WelcomeBinarySensor(data, camera_name,
|
||||
module_name, home,
|
||||
timeout, variable)])
|
||||
|
||||
|
||||
class WelcomeBinarySensor(BinarySensorDevice):
|
||||
"""Represent a single binary sensor in a Netatmo Welcome device."""
|
||||
|
||||
def __init__(self, data, camera_name, home, timeout, sensor):
|
||||
def __init__(self, data, camera_name, module_name, home, timeout, sensor):
|
||||
"""Setup 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
|
||||
if home:
|
||||
self._name = home + ' / ' + camera_name
|
||||
else:
|
||||
self._name = camera_name
|
||||
if module_name:
|
||||
self._name += ' / ' + module_name
|
||||
self._sensor_name = sensor
|
||||
self._name += ' ' + sensor
|
||||
camera_id = data.welcomedata.cameraByName(camera=camera_name,
|
||||
@@ -112,7 +128,7 @@ class WelcomeBinarySensor(BinarySensorDevice):
|
||||
def update(self):
|
||||
"""Request an update from the Netatmo API."""
|
||||
self._data.update()
|
||||
self._data.welcomedata.updateEvent(home=self._data.home)
|
||||
self._data.update_event()
|
||||
|
||||
if self._sensor_name == "Someone known":
|
||||
self._state =\
|
||||
@@ -129,5 +145,16 @@ class WelcomeBinarySensor(BinarySensorDevice):
|
||||
self._data.welcomedata.motionDetected(self._home,
|
||||
self._camera_name,
|
||||
self._timeout*60)
|
||||
elif self._sensor_name == "Tag Vibration":
|
||||
self._state =\
|
||||
self._data.welcomedata.moduleMotionDetected(self._home,
|
||||
self._module_name,
|
||||
self._camera_name,
|
||||
self._timeout*60)
|
||||
elif self._sensor_name == "Tag Open":
|
||||
self._state =\
|
||||
self._data.welcomedata.moduleOpened(self._home,
|
||||
self._module_name,
|
||||
self._camera_name)
|
||||
else:
|
||||
return None
|
||||
|
||||
128
homeassistant/components/binary_sensor/threshold.py
Normal file
128
homeassistant/components/binary_sensor/threshold.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Support for monitoring if a sensor value is below/above a threshold.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/sensor.threshold/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA, SENSOR_CLASSES_SCHEMA)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_ENTITY_ID, CONF_TYPE, STATE_UNKNOWN, CONF_SENSOR_CLASS,
|
||||
ATTR_ENTITY_ID)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_SENSOR_VALUE = 'sensor_value'
|
||||
ATTR_THRESHOLD = 'threshold'
|
||||
ATTR_TYPE = 'type'
|
||||
|
||||
CONF_LOWER = 'lower'
|
||||
CONF_THRESHOLD = 'threshold'
|
||||
CONF_UPPER = 'upper'
|
||||
|
||||
DEFAULT_NAME = 'Threshold'
|
||||
|
||||
SENSOR_TYPES = [CONF_LOWER, CONF_UPPER]
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(CONF_THRESHOLD): vol.Coerce(float),
|
||||
vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_SENSOR_CLASS, default=None): SENSOR_CLASSES_SCHEMA,
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Threshold sensor."""
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
name = config.get(CONF_NAME)
|
||||
threshold = config.get(CONF_THRESHOLD)
|
||||
limit_type = config.get(CONF_TYPE)
|
||||
sensor_class = config.get(CONF_SENSOR_CLASS)
|
||||
|
||||
yield from async_add_devices(
|
||||
[ThresholdSensor(hass, entity_id, name, threshold, limit_type,
|
||||
sensor_class)], True)
|
||||
return True
|
||||
|
||||
|
||||
class ThresholdSensor(BinarySensorDevice):
|
||||
"""Representation of a Threshold sensor."""
|
||||
|
||||
def __init__(self, hass, entity_id, name, threshold, limit_type,
|
||||
sensor_class):
|
||||
"""Initialize the Threshold sensor."""
|
||||
self._hass = hass
|
||||
self._entity_id = entity_id
|
||||
self.is_upper = limit_type == 'upper'
|
||||
self._name = name
|
||||
self._threshold = threshold
|
||||
self._sensor_class = sensor_class
|
||||
self._deviation = False
|
||||
self.sensor_value = 0
|
||||
|
||||
@callback
|
||||
# pylint: disable=invalid-name
|
||||
def async_threshold_sensor_state_listener(
|
||||
entity, old_state, new_state):
|
||||
"""Called when the sensor changes state."""
|
||||
if new_state.state == STATE_UNKNOWN:
|
||||
return
|
||||
|
||||
try:
|
||||
self.sensor_value = float(new_state.state)
|
||||
except ValueError:
|
||||
_LOGGER.error("State is not numerical")
|
||||
|
||||
hass.async_add_job(self.async_update_ha_state, True)
|
||||
|
||||
async_track_state_change(
|
||||
hass, entity_id, async_threshold_sensor_state_listener)
|
||||
|
||||
@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._deviation
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def sensor_class(self):
|
||||
"""Return the sensor class of the sensor."""
|
||||
return self._sensor_class
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return the state attributes of the sensor."""
|
||||
return {
|
||||
ATTR_ENTITY_ID: self._entity_id,
|
||||
ATTR_SENSOR_VALUE: self.sensor_value,
|
||||
ATTR_THRESHOLD: self._threshold,
|
||||
ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER,
|
||||
}
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
"""Get the latest data and updates the states."""
|
||||
if self.is_upper:
|
||||
self._deviation = bool(self.sensor_value > self._threshold)
|
||||
else:
|
||||
self._deviation = bool(self.sensor_value < self._threshold)
|
||||
@@ -4,8 +4,6 @@ Support for Wink binary sensors.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
at https://home-assistant.io/components/binary_sensor.wink/
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.sensor.wink import WinkDevice
|
||||
@@ -34,38 +32,28 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
|
||||
for sensor in pywink.get_sensors():
|
||||
if sensor.capability() in SENSOR_TYPES:
|
||||
add_devices([WinkBinarySensorDevice(sensor)])
|
||||
add_devices([WinkBinarySensorDevice(sensor, hass)])
|
||||
|
||||
for key in pywink.get_keys():
|
||||
add_devices([WinkBinarySensorDevice(key)])
|
||||
add_devices([WinkBinarySensorDevice(key, hass)])
|
||||
|
||||
for sensor in pywink.get_smoke_and_co_detectors():
|
||||
add_devices([WinkBinarySensorDevice(sensor)])
|
||||
add_devices([WinkBinarySensorDevice(sensor, hass)])
|
||||
|
||||
for hub in pywink.get_hubs():
|
||||
add_devices([WinkHub(hub, hass)])
|
||||
|
||||
|
||||
class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity):
|
||||
"""Representation of a Wink binary sensor."""
|
||||
|
||||
def __init__(self, wink):
|
||||
def __init__(self, wink, hass):
|
||||
"""Initialize the Wink binary sensor."""
|
||||
super().__init__(wink)
|
||||
super().__init__(wink, hass)
|
||||
wink = get_component('wink')
|
||||
self._unit_of_measurement = self.wink.UNIT
|
||||
self.capability = self.wink.capability()
|
||||
|
||||
def _pubnub_update(self, message, channel):
|
||||
try:
|
||||
if 'data' in message:
|
||||
json_data = json.dumps(message.get('data'))
|
||||
else:
|
||||
json_data = message
|
||||
self.wink.pubnub_update(json.loads(json_data))
|
||||
self.update_ha_state()
|
||||
except (AttributeError, KeyError):
|
||||
error = "Pubnub returned invalid json for " + self.name
|
||||
logging.getLogger(__name__).error(error)
|
||||
self.update_ha_state(True)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
@@ -94,3 +82,24 @@ class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity):
|
||||
def sensor_class(self):
|
||||
"""Return the class of this sensor, from SENSOR_CLASSES."""
|
||||
return SENSOR_TYPES.get(self.capability)
|
||||
|
||||
|
||||
class WinkHub(WinkDevice, BinarySensorDevice, Entity):
|
||||
"""Representation of a Wink Hub."""
|
||||
|
||||
def __init(self, wink, hass):
|
||||
"""Initialize the hub sensor."""
|
||||
WinkDevice.__init__(self, wink, hass)
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
'update needed': self.wink.update_needed(),
|
||||
'firmware version': self.wink.firmware_version()
|
||||
}
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.wink.state()
|
||||
|
||||
@@ -13,7 +13,7 @@ from aiohttp import web
|
||||
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
|
||||
from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED
|
||||
|
||||
DOMAIN = 'camera'
|
||||
DEPENDENCIES = ['http']
|
||||
@@ -33,8 +33,8 @@ def async_setup(hass, config):
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
hass.http.register_view(CameraImageView(hass, component.entities))
|
||||
hass.http.register_view(CameraMjpegStream(hass, component.entities))
|
||||
hass.http.register_view(CameraImageView(component.entities))
|
||||
hass.http.register_view(CameraMjpegStream(component.entities))
|
||||
|
||||
yield from component.async_setup(config)
|
||||
return True
|
||||
@@ -165,9 +165,8 @@ class CameraView(HomeAssistantView):
|
||||
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass, entities):
|
||||
def __init__(self, entities):
|
||||
"""Initialize a basic camera view."""
|
||||
super().__init__(hass)
|
||||
self.entities = entities
|
||||
|
||||
@asyncio.coroutine
|
||||
@@ -178,7 +177,7 @@ class CameraView(HomeAssistantView):
|
||||
if camera is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
authenticated = (request.authenticated or
|
||||
authenticated = (request[KEY_AUTHENTICATED] or
|
||||
request.GET.get('token') == camera.access_token)
|
||||
|
||||
if not authenticated:
|
||||
|
||||
90
homeassistant/components/camera/amcrest.py
Normal file
90
homeassistant/components/camera/amcrest.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
This component provides basic support for Amcrest IP cameras.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.amcrest/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['amcrest==1.0.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_RESOLUTION = 'resolution'
|
||||
|
||||
DEFAULT_NAME = 'Amcrest Camera'
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_RESOLUTION = 'high'
|
||||
|
||||
NOTIFICATION_ID = 'amcrest_notification'
|
||||
NOTIFICATION_TITLE = 'Amcrest Camera Setup'
|
||||
|
||||
RESOLUTION_LIST = {
|
||||
'high': 0,
|
||||
'low': 1,
|
||||
}
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION):
|
||||
vol.All(vol.In(RESOLUTION_LIST)),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up an Amcrest IP Camera."""
|
||||
from amcrest import AmcrestCamera
|
||||
data = AmcrestCamera(
|
||||
config.get(CONF_HOST), config.get(CONF_PORT),
|
||||
config.get(CONF_USERNAME), config.get(CONF_PASSWORD))
|
||||
|
||||
persistent_notification = loader.get_component('persistent_notification')
|
||||
try:
|
||||
data.camera.current_time
|
||||
# pylint: disable=broad-except
|
||||
except Exception as ex:
|
||||
_LOGGER.error("Unable to connect to Amcrest camera: %s", str(ex))
|
||||
persistent_notification.create(
|
||||
hass, 'Error: {}<br />'
|
||||
'You will need to restart hass after fixing.'
|
||||
''.format(ex),
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID)
|
||||
return False
|
||||
|
||||
add_devices([AmcrestCam(config, data)])
|
||||
return True
|
||||
|
||||
|
||||
class AmcrestCam(Camera):
|
||||
"""An implementation of an Amcrest IP camera."""
|
||||
|
||||
def __init__(self, device_info, data):
|
||||
"""Initialize an Amcrest camera."""
|
||||
super(AmcrestCam, self).__init__()
|
||||
self._data = data
|
||||
self._name = device_info.get(CONF_NAME)
|
||||
self._resolution = RESOLUTION_LIST[device_info.get(CONF_RESOLUTION)]
|
||||
|
||||
def camera_image(self):
|
||||
"""Return a still image reponse from the camera."""
|
||||
# Send the request to snap a picture and return raw jpg data
|
||||
response = self._data.camera.snapshot(channel=self._resolution)
|
||||
return response.data
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this camera."""
|
||||
return self._name
|
||||
@@ -18,6 +18,7 @@ from homeassistant.const import (
|
||||
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
|
||||
@@ -96,8 +97,7 @@ class GenericCamera(Camera):
|
||||
def fetch():
|
||||
"""Read image from a URL."""
|
||||
try:
|
||||
kwargs = {'timeout': 10, 'auth': self._auth}
|
||||
response = requests.get(url, **kwargs)
|
||||
response = requests.get(url, timeout=10, auth=self._auth)
|
||||
return response.content
|
||||
except requests.exceptions.RequestException as error:
|
||||
_LOGGER.error('Error getting camera image: %s', error)
|
||||
@@ -107,12 +107,13 @@ class GenericCamera(Camera):
|
||||
None, fetch)
|
||||
# async
|
||||
else:
|
||||
response = None
|
||||
try:
|
||||
websession = async_get_clientsession(self.hass)
|
||||
with async_timeout.timeout(10, loop=self.hass.loop):
|
||||
response = yield from self.hass.websession.get(
|
||||
response = yield from websession.get(
|
||||
url, auth=self._auth)
|
||||
self._last_image = yield from response.read()
|
||||
yield from response.release()
|
||||
self._last_image = yield from response.read()
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error('Timeout getting camera image')
|
||||
return self._last_image
|
||||
@@ -120,6 +121,9 @@ class GenericCamera(Camera):
|
||||
aiohttp.errors.ClientDisconnectedError) as err:
|
||||
_LOGGER.error('Error getting new camera image: %s', err)
|
||||
return self._last_image
|
||||
finally:
|
||||
if response is not None:
|
||||
self.hass.async_add_job(response.release())
|
||||
|
||||
self._last_url = url
|
||||
return self._last_image
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.const import (
|
||||
CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION,
|
||||
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
|
||||
from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -101,29 +102,33 @@ class MjpegCamera(Camera):
|
||||
return
|
||||
|
||||
# connect to stream
|
||||
websession = async_get_clientsession(self.hass)
|
||||
stream = None
|
||||
response = None
|
||||
try:
|
||||
with async_timeout.timeout(10, loop=self.hass.loop):
|
||||
stream = yield from self.hass.websession.get(
|
||||
self._mjpeg_url,
|
||||
auth=self._auth
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPGatewayTimeout()
|
||||
stream = yield from websession.get(self._mjpeg_url,
|
||||
auth=self._auth)
|
||||
|
||||
response = web.StreamResponse()
|
||||
response.content_type = stream.headers.get(CONTENT_TYPE_HEADER)
|
||||
response = web.StreamResponse()
|
||||
response.content_type = stream.headers.get(CONTENT_TYPE_HEADER)
|
||||
|
||||
yield from response.prepare(request)
|
||||
yield from response.prepare(request)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = yield from stream.content.read(102400)
|
||||
if not data:
|
||||
break
|
||||
response.write(data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPGatewayTimeout()
|
||||
|
||||
finally:
|
||||
self.hass.async_add_job(stream.release())
|
||||
yield from response.write_eof()
|
||||
if stream is not None:
|
||||
self.hass.async_add_job(stream.release())
|
||||
if response is not None:
|
||||
yield from response.write_eof()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
||||
109
homeassistant/components/camera/nest.py
Normal file
109
homeassistant/components/camera/nest.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Support for Nest Cameras.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.nest/
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
|
||||
import homeassistant.components.nest as nest
|
||||
from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera)
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['nest']
|
||||
|
||||
NEST_BRAND = 'Nest'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up a Nest Cam."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
camera_devices = hass.data[nest.DATA_NEST].camera_devices()
|
||||
cameras = [NestCamera(structure, device)
|
||||
for structure, device in camera_devices]
|
||||
add_devices(cameras, True)
|
||||
|
||||
|
||||
class NestCamera(Camera):
|
||||
"""Representation of a Nest Camera."""
|
||||
|
||||
def __init__(self, structure, device):
|
||||
"""Initialize a Nest Camera."""
|
||||
super(NestCamera, self).__init__()
|
||||
self.structure = structure
|
||||
self.device = device
|
||||
self._location = None
|
||||
self._name = None
|
||||
self._is_online = None
|
||||
self._is_streaming = None
|
||||
self._is_video_history_enabled = False
|
||||
# Default to non-NestAware subscribed, but will be fixed during update
|
||||
self._time_between_snapshots = timedelta(seconds=30)
|
||||
self._last_image = None
|
||||
self._next_snapshot_at = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the nest, if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Nest camera should poll periodically."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_recording(self):
|
||||
"""Return true if the device is recording."""
|
||||
return self._is_streaming
|
||||
|
||||
@property
|
||||
def brand(self):
|
||||
"""Return the brand of the camera."""
|
||||
return NEST_BRAND
|
||||
|
||||
# This doesn't seem to be getting called regularly, for some reason
|
||||
def update(self):
|
||||
"""Cache value from Python-nest."""
|
||||
self._location = self.device.where
|
||||
self._name = self.device.name
|
||||
self._is_online = self.device.is_online
|
||||
self._is_streaming = self.device.is_streaming
|
||||
self._is_video_history_enabled = self.device.is_video_history_enabled
|
||||
|
||||
if self._is_video_history_enabled:
|
||||
# NestAware allowed 10/min
|
||||
self._time_between_snapshots = timedelta(seconds=6)
|
||||
else:
|
||||
# Otherwise, 2/min
|
||||
self._time_between_snapshots = timedelta(seconds=30)
|
||||
|
||||
def _ready_for_snapshot(self, now):
|
||||
return (self._next_snapshot_at is None or
|
||||
now > self._next_snapshot_at)
|
||||
|
||||
def camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
now = utcnow()
|
||||
if self._ready_for_snapshot(now):
|
||||
url = self.device.snapshot_url
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
except requests.exceptions.RequestException as error:
|
||||
_LOGGER.error("Error getting camera image: %s", error)
|
||||
return None
|
||||
|
||||
self._next_snapshot_at = now + self._time_between_snapshots
|
||||
self._last_image = response.content
|
||||
|
||||
return self._last_image
|
||||
@@ -39,7 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_FILE_PATH): cv.string,
|
||||
vol.Optional(CONF_HORIZONTAL_FLIP, default=DEFAULT_HORIZONTAL_FLIP):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=0, max=1)),
|
||||
vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_HORIZONTAL_FLIP):
|
||||
vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_IMAGE_HEIGHT):
|
||||
vol.Coerce(int),
|
||||
vol.Optional(CONF_IMAGE_QUALITY, default=DEFAULT_IMAGE_QUALITIY):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
|
||||
|
||||
@@ -14,12 +14,13 @@ from aiohttp import web
|
||||
from aiohttp.web_exceptions import HTTPGatewayTimeout
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP)
|
||||
CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL)
|
||||
from homeassistant.components.camera import (
|
||||
Camera, PLATFORM_SCHEMA)
|
||||
from homeassistant.helpers.aiohttp_client import (
|
||||
async_get_clientsession, async_create_clientsession)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
|
||||
@@ -59,23 +60,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Setup a Synology IP Camera."""
|
||||
if not config.get(CONF_VERIFY_SSL):
|
||||
connector = aiohttp.TCPConnector(verify_ssl=False)
|
||||
|
||||
@asyncio.coroutine
|
||||
def _async_close_connector(event):
|
||||
"""Close websession on shutdown."""
|
||||
yield from connector.close()
|
||||
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, _async_close_connector)
|
||||
else:
|
||||
connector = hass.websession.connector
|
||||
|
||||
websession_init = aiohttp.ClientSession(
|
||||
loop=hass.loop,
|
||||
connector=connector
|
||||
)
|
||||
verify_ssl = config.get(CONF_VERIFY_SSL)
|
||||
websession_init = async_get_clientsession(hass, verify_ssl)
|
||||
|
||||
# Determine API to use for authentication
|
||||
syno_api_url = SYNO_API_URL.format(
|
||||
@@ -87,24 +73,27 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
'version': '1',
|
||||
'query': 'SYNO.'
|
||||
}
|
||||
query_req = None
|
||||
try:
|
||||
with async_timeout.timeout(TIMEOUT, loop=hass.loop):
|
||||
query_req = yield from websession_init.get(
|
||||
syno_api_url,
|
||||
params=query_payload
|
||||
)
|
||||
|
||||
query_resp = yield from query_req.json()
|
||||
auth_path = query_resp['data'][AUTH_API]['path']
|
||||
camera_api = query_resp['data'][CAMERA_API]['path']
|
||||
camera_path = query_resp['data'][CAMERA_API]['path']
|
||||
streaming_path = query_resp['data'][STREAMING_API]['path']
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.errors.ClientError):
|
||||
_LOGGER.exception("Error on %s", syno_api_url)
|
||||
return False
|
||||
|
||||
query_resp = yield from query_req.json()
|
||||
auth_path = query_resp['data'][AUTH_API]['path']
|
||||
camera_api = query_resp['data'][CAMERA_API]['path']
|
||||
camera_path = query_resp['data'][CAMERA_API]['path']
|
||||
streaming_path = query_resp['data'][STREAMING_API]['path']
|
||||
|
||||
# cleanup
|
||||
yield from query_req.release()
|
||||
finally:
|
||||
if query_req is not None:
|
||||
yield from query_req.release()
|
||||
|
||||
# Authticate to NAS to get a session id
|
||||
syno_auth_url = SYNO_API_URL.format(
|
||||
@@ -118,19 +107,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
syno_auth_url
|
||||
)
|
||||
|
||||
websession_init.detach()
|
||||
|
||||
# init websession
|
||||
websession = aiohttp.ClientSession(
|
||||
loop=hass.loop, connector=connector, cookies={'id': session_id})
|
||||
|
||||
@callback
|
||||
def _async_close_websession(event):
|
||||
"""Close websession on shutdown."""
|
||||
websession.detach()
|
||||
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, _async_close_websession)
|
||||
websession = async_create_clientsession(
|
||||
hass, verify_ssl, cookies={'id': session_id})
|
||||
|
||||
# Use SessionID to get cameras in system
|
||||
syno_camera_url = SYNO_API_URL.format(
|
||||
@@ -190,20 +169,23 @@ def get_session_id(hass, websession, username, password, login_url):
|
||||
'session': 'SurveillanceStation',
|
||||
'format': 'sid'
|
||||
}
|
||||
auth_req = None
|
||||
try:
|
||||
with async_timeout.timeout(TIMEOUT, loop=hass.loop):
|
||||
auth_req = yield from websession.get(
|
||||
login_url,
|
||||
params=auth_payload
|
||||
)
|
||||
auth_resp = yield from auth_req.json()
|
||||
return auth_resp['data']['sid']
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.errors.ClientError):
|
||||
_LOGGER.exception("Error on %s", login_url)
|
||||
return False
|
||||
|
||||
auth_resp = yield from auth_req.json()
|
||||
yield from auth_req.release()
|
||||
|
||||
return auth_resp['data']['sid']
|
||||
finally:
|
||||
if auth_req is not None:
|
||||
yield from auth_req.release()
|
||||
|
||||
|
||||
class SynologyCamera(Camera):
|
||||
@@ -271,30 +253,34 @@ class SynologyCamera(Camera):
|
||||
'cameraId': self._camera_id,
|
||||
'format': 'mjpeg'
|
||||
}
|
||||
stream = None
|
||||
response = None
|
||||
try:
|
||||
with async_timeout.timeout(TIMEOUT, loop=self.hass.loop):
|
||||
stream = yield from self._websession.get(
|
||||
streaming_url,
|
||||
params=streaming_payload
|
||||
)
|
||||
except (asyncio.TimeoutError, aiohttp.errors.ClientError):
|
||||
_LOGGER.exception("Error on %s", streaming_url)
|
||||
raise HTTPGatewayTimeout()
|
||||
response = web.StreamResponse()
|
||||
response.content_type = stream.headers.get(CONTENT_TYPE_HEADER)
|
||||
|
||||
response = web.StreamResponse()
|
||||
response.content_type = stream.headers.get(CONTENT_TYPE_HEADER)
|
||||
yield from response.prepare(request)
|
||||
|
||||
yield from response.prepare(request)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = yield from stream.content.read(102400)
|
||||
if not data:
|
||||
break
|
||||
response.write(data)
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.errors.ClientError):
|
||||
_LOGGER.exception("Error on %s", streaming_url)
|
||||
raise HTTPGatewayTimeout()
|
||||
|
||||
finally:
|
||||
self.hass.async_add_job(stream.release())
|
||||
yield from response.write_eof()
|
||||
if stream is not None:
|
||||
self.hass.async_add_job(stream.release())
|
||||
if response is not None:
|
||||
yield from response.write_eof()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
||||
@@ -58,6 +58,11 @@ ATTR_OPERATION_LIST = "operation_list"
|
||||
ATTR_SWING_MODE = "swing_mode"
|
||||
ATTR_SWING_LIST = "swing_list"
|
||||
|
||||
# The degree of precision for each platform
|
||||
PRECISION_WHOLE = 1
|
||||
PRECISION_HALVES = 0.5
|
||||
PRECISION_TENTHS = 0.1
|
||||
|
||||
CONVERTIBLE_ATTRIBUTE = [
|
||||
ATTR_TEMPERATURE,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
@@ -371,6 +376,14 @@ class ClimateDevice(Entity):
|
||||
else:
|
||||
return STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
"""Return the precision of the system."""
|
||||
if self.unit_of_measurement == TEMP_CELSIUS:
|
||||
return PRECISION_TENTHS
|
||||
else:
|
||||
return PRECISION_WHOLE
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return the optional state attributes."""
|
||||
@@ -562,16 +575,18 @@ class ClimateDevice(Entity):
|
||||
|
||||
def _convert_for_display(self, temp):
|
||||
"""Convert temperature into preferred units for display purposes."""
|
||||
if temp is None or not isinstance(temp, Number):
|
||||
if (temp is None or not isinstance(temp, Number) or
|
||||
self.temperature_unit == self.unit_of_measurement):
|
||||
return temp
|
||||
|
||||
value = convert_temperature(temp, self.temperature_unit,
|
||||
self.unit_of_measurement)
|
||||
|
||||
if self.unit_of_measurement is TEMP_CELSIUS:
|
||||
decimal_count = 1
|
||||
# Round in the units appropriate
|
||||
if self.precision == PRECISION_HALVES:
|
||||
return round(value * 2) / 2.0
|
||||
elif self.precision == PRECISION_TENTHS:
|
||||
return round(value, 1)
|
||||
else:
|
||||
# Users of fahrenheit generally expect integer units.
|
||||
decimal_count = 0
|
||||
|
||||
return round(value, decimal_count)
|
||||
# PRECISION_WHOLE as a fall back
|
||||
return round(value)
|
||||
|
||||
@@ -195,8 +195,9 @@ class Thermostat(ClimateDevice):
|
||||
mode = self.mode
|
||||
events = self.thermostat['events']
|
||||
for event in events:
|
||||
if event['running']:
|
||||
mode = event['holdClimateRef']
|
||||
if event['holdClimateRef'] == 'away' or \
|
||||
event['type'] == 'autoAway':
|
||||
mode = "away"
|
||||
break
|
||||
return 'away' in mode
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['switch', 'sensor']
|
||||
|
||||
TOL_TEMP = 0.3
|
||||
DEFAULT_TOLERANCE = 0.3
|
||||
DEFAULT_NAME = 'Generic Thermostat'
|
||||
|
||||
CONF_NAME = 'name'
|
||||
DEFAULT_NAME = 'Generic Thermostat'
|
||||
CONF_HEATER = 'heater'
|
||||
CONF_SENSOR = 'target_sensor'
|
||||
CONF_MIN_TEMP = 'min_temp'
|
||||
@@ -32,6 +32,7 @@ CONF_MAX_TEMP = 'max_temp'
|
||||
CONF_TARGET_TEMP = 'target_temp'
|
||||
CONF_AC_MODE = 'ac_mode'
|
||||
CONF_MIN_DUR = 'min_cycle_duration'
|
||||
CONF_TOLERANCE = 'tolerance'
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
@@ -42,6 +43,7 @@ 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_TARGET_TEMP): vol.Coerce(float),
|
||||
})
|
||||
|
||||
@@ -56,23 +58,26 @@ def setup_platform(hass, config, 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)
|
||||
|
||||
add_devices([GenericThermostat(
|
||||
hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp,
|
||||
target_temp, ac_mode, min_cycle_duration)])
|
||||
target_temp, ac_mode, min_cycle_duration, tolerance)])
|
||||
|
||||
|
||||
class GenericThermostat(ClimateDevice):
|
||||
"""Representation of a GenericThermostat device."""
|
||||
|
||||
def __init__(self, hass, name, heater_entity_id, sensor_entity_id,
|
||||
min_temp, max_temp, target_temp, ac_mode, min_cycle_duration):
|
||||
min_temp, max_temp, target_temp, ac_mode, min_cycle_duration,
|
||||
tolerance):
|
||||
"""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._active = False
|
||||
self._cur_temp = None
|
||||
@@ -193,24 +198,30 @@ class GenericThermostat(ClimateDevice):
|
||||
return
|
||||
|
||||
if self.ac_mode:
|
||||
too_hot = self._cur_temp - self._target_temp > TOL_TEMP
|
||||
is_cooling = self._is_device_active
|
||||
if too_hot and not is_cooling:
|
||||
_LOGGER.info('Turning on AC %s', self.heater_entity_id)
|
||||
switch.turn_on(self.hass, self.heater_entity_id)
|
||||
elif not too_hot and is_cooling:
|
||||
_LOGGER.info('Turning off AC %s', self.heater_entity_id)
|
||||
switch.turn_off(self.hass, self.heater_entity_id)
|
||||
if is_cooling:
|
||||
too_cold = self._target_temp - self._cur_temp > self._tolerance
|
||||
if too_cold:
|
||||
_LOGGER.info('Turning off AC %s', self.heater_entity_id)
|
||||
switch.turn_off(self.hass, self.heater_entity_id)
|
||||
else:
|
||||
too_hot = self._cur_temp - self._target_temp > self._tolerance
|
||||
if too_hot:
|
||||
_LOGGER.info('Turning on AC %s', self.heater_entity_id)
|
||||
switch.turn_on(self.hass, self.heater_entity_id)
|
||||
else:
|
||||
too_cold = self._target_temp - self._cur_temp > TOL_TEMP
|
||||
is_heating = self._is_device_active
|
||||
|
||||
if too_cold and not is_heating:
|
||||
_LOGGER.info('Turning on heater %s', self.heater_entity_id)
|
||||
switch.turn_on(self.hass, self.heater_entity_id)
|
||||
elif not too_cold and is_heating:
|
||||
_LOGGER.info('Turning off heater %s', self.heater_entity_id)
|
||||
switch.turn_off(self.hass, self.heater_entity_id)
|
||||
if is_heating:
|
||||
too_hot = self._cur_temp - self._target_temp > self._tolerance
|
||||
if too_hot:
|
||||
_LOGGER.info('Turning off heater %s',
|
||||
self.heater_entity_id)
|
||||
switch.turn_off(self.hass, self.heater_entity_id)
|
||||
else:
|
||||
too_cold = self._target_temp - self._cur_temp > self._tolerance
|
||||
if too_cold:
|
||||
_LOGGER.info('Turning on heater %s', self.heater_entity_id)
|
||||
switch.turn_on(self.hass, self.heater_entity_id)
|
||||
|
||||
@property
|
||||
def _is_device_active(self):
|
||||
|
||||
@@ -5,10 +5,11 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.homematic/
|
||||
"""
|
||||
import logging
|
||||
import homeassistant.components.homematic as homematic
|
||||
from homeassistant.components.climate import ClimateDevice, STATE_AUTO
|
||||
from homeassistant.components.homematic import HMDevice
|
||||
from homeassistant.util.temperature import convert
|
||||
from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN, ATTR_TEMPERATURE
|
||||
from homeassistant.loader import get_component
|
||||
|
||||
DEPENDENCIES = ['homematic']
|
||||
|
||||
@@ -29,14 +30,16 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None):
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
homematic = get_component("homematic")
|
||||
return homematic.setup_hmdevice_discovery_helper(
|
||||
hass,
|
||||
HMThermostat,
|
||||
discovery_info,
|
||||
add_callback_devices
|
||||
)
|
||||
|
||||
|
||||
class HMThermostat(homematic.HMDevice, ClimateDevice):
|
||||
class HMThermostat(HMDevice, ClimateDevice):
|
||||
"""Representation of a Homematic thermostat."""
|
||||
|
||||
@property
|
||||
@@ -94,13 +97,9 @@ class HMThermostat(homematic.HMDevice, ClimateDevice):
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
if not self.available:
|
||||
if not self.available or temperature is None:
|
||||
return None
|
||||
if temperature is None:
|
||||
return
|
||||
|
||||
if self.current_operation == STATE_AUTO:
|
||||
return self._hmdevice.actionNodeData('MANU_MODE', temperature)
|
||||
self._hmdevice.set_temperature(temperature)
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
|
||||
@@ -14,7 +14,8 @@ from homeassistant.components.climate import (
|
||||
PLATFORM_SCHEMA, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
|
||||
ATTR_TEMPERATURE)
|
||||
from homeassistant.const import (
|
||||
TEMP_CELSIUS, CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN)
|
||||
TEMP_CELSIUS, TEMP_FAHRENHEIT,
|
||||
CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN)
|
||||
|
||||
DEPENDENCIES = ['nest']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -24,10 +25,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
})
|
||||
|
||||
STATE_ECO = 'eco'
|
||||
STATE_HEAT_COOL = 'heat-cool'
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the Nest thermostat."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Setting up nest thermostat")
|
||||
|
||||
temp_unit = hass.config.units.temperature_unit
|
||||
|
||||
add_devices(
|
||||
[NestThermostat(structure, device, temp_unit)
|
||||
for structure, device in hass.data[DATA_NEST].devices()],
|
||||
@@ -58,9 +68,9 @@ class NestThermostat(ClimateDevice):
|
||||
if self.device.can_heat and self.device.can_cool:
|
||||
self._operation_list.append(STATE_AUTO)
|
||||
|
||||
self._operation_list.append(STATE_ECO)
|
||||
|
||||
# feature of device
|
||||
self._has_humidifier = self.device.has_humidifier
|
||||
self._has_dehumidifier = self.device.has_dehumidifier
|
||||
self._has_fan = self.device.has_fan
|
||||
|
||||
# data attributes
|
||||
@@ -68,41 +78,26 @@ class NestThermostat(ClimateDevice):
|
||||
self._location = None
|
||||
self._name = None
|
||||
self._humidity = None
|
||||
self._target_humidity = None
|
||||
self._target_temperature = None
|
||||
self._temperature = None
|
||||
self._temperature_scale = None
|
||||
self._mode = None
|
||||
self._fan = None
|
||||
self._away_temperature = None
|
||||
self._eco_temperature = None
|
||||
self._is_locked = None
|
||||
self._locked_temperature = None
|
||||
self._min_temperature = None
|
||||
self._max_temperature = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the nest, if any."""
|
||||
if self._location is None:
|
||||
return self._name
|
||||
else:
|
||||
if self._name == '':
|
||||
return self._location.capitalize()
|
||||
else:
|
||||
return self._location.capitalize() + '(' + self._name + ')'
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device specific state attributes."""
|
||||
if self._has_humidifier or self._has_dehumidifier:
|
||||
# Move these to Thermostat Device and make them global
|
||||
return {
|
||||
"humidity": self._humidity,
|
||||
"target_humidity": self._target_humidity,
|
||||
}
|
||||
else:
|
||||
# No way to control humidity not show setting
|
||||
return {}
|
||||
return self._temperature_scale
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
@@ -112,21 +107,17 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
if self._mode == 'cool':
|
||||
return STATE_COOL
|
||||
elif self._mode == 'heat':
|
||||
return STATE_HEAT
|
||||
elif self._mode == 'range':
|
||||
if self._mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]:
|
||||
return self._mode
|
||||
elif self._mode == STATE_HEAT_COOL:
|
||||
return STATE_AUTO
|
||||
elif self._mode == 'off':
|
||||
return STATE_OFF
|
||||
else:
|
||||
return STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
if self._mode != 'range' and not self.is_away_mode_on:
|
||||
if self._mode != STATE_HEAT_COOL and not self.is_away_mode_on:
|
||||
return self._target_temperature
|
||||
else:
|
||||
return None
|
||||
@@ -134,10 +125,11 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def target_temperature_low(self):
|
||||
"""Return the lower bound temperature we try to reach."""
|
||||
if self.is_away_mode_on and self._away_temperature[0]:
|
||||
# away_temperature is always a low, high tuple
|
||||
return self._away_temperature[0]
|
||||
if self._mode == 'range':
|
||||
if (self.is_away_mode_on or self._mode == STATE_ECO) and \
|
||||
self._eco_temperature[0]:
|
||||
# eco_temperature is always a low, high tuple
|
||||
return self._eco_temperature[0]
|
||||
if self._mode == STATE_HEAT_COOL:
|
||||
return self._target_temperature[0]
|
||||
else:
|
||||
return None
|
||||
@@ -145,10 +137,11 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def target_temperature_high(self):
|
||||
"""Return the upper bound temperature we try to reach."""
|
||||
if self.is_away_mode_on and self._away_temperature[1]:
|
||||
# away_temperature is always a low, high tuple
|
||||
return self._away_temperature[1]
|
||||
if self._mode == 'range':
|
||||
if (self.is_away_mode_on or self._mode == STATE_ECO) and \
|
||||
self._eco_temperature[1]:
|
||||
# eco_temperature is always a low, high tuple
|
||||
return self._eco_temperature[1]
|
||||
if self._mode == STATE_HEAT_COOL:
|
||||
return self._target_temperature[1]
|
||||
else:
|
||||
return None
|
||||
@@ -162,9 +155,8 @@ class NestThermostat(ClimateDevice):
|
||||
"""Set new target temperature."""
|
||||
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
||||
target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
||||
if target_temp_low is not None and target_temp_high is not None:
|
||||
|
||||
if self._mode == 'range':
|
||||
if self._mode == STATE_HEAT_COOL:
|
||||
if target_temp_low is not None and target_temp_high is not None:
|
||||
temp = (target_temp_low, target_temp_high)
|
||||
else:
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
@@ -173,14 +165,11 @@ class NestThermostat(ClimateDevice):
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
if operation_mode == STATE_HEAT:
|
||||
self.device.mode = 'heat'
|
||||
elif operation_mode == STATE_COOL:
|
||||
self.device.mode = 'cool'
|
||||
if operation_mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]:
|
||||
device_mode = operation_mode
|
||||
elif operation_mode == STATE_AUTO:
|
||||
self.device.mode = 'range'
|
||||
elif operation_mode == STATE_OFF:
|
||||
self.device.mode = 'off'
|
||||
device_mode = STATE_HEAT_COOL
|
||||
self.device.mode = device_mode
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
@@ -217,30 +206,29 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Identify min_temp in Nest API or defaults if not available."""
|
||||
temp = self._away_temperature[0]
|
||||
if temp is None:
|
||||
return super().min_temp
|
||||
else:
|
||||
return temp
|
||||
return self._min_temperature
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Identify max_temp in Nest API or defaults if not available."""
|
||||
temp = self._away_temperature[1]
|
||||
if temp is None:
|
||||
return super().max_temp
|
||||
else:
|
||||
return temp
|
||||
return self._max_temperature
|
||||
|
||||
def update(self):
|
||||
"""Cache value from Python-nest."""
|
||||
self._location = self.device.where
|
||||
self._name = self.device.name
|
||||
self._humidity = self.device.humidity,
|
||||
self._target_humidity = self.device.target_humidity,
|
||||
self._temperature = self.device.temperature
|
||||
self._mode = self.device.mode
|
||||
self._target_temperature = self.device.target
|
||||
self._fan = self.device.fan
|
||||
self._away = self.structure.away
|
||||
self._away_temperature = self.device.away_temperature
|
||||
self._away = self.structure.away == 'away'
|
||||
self._eco_temperature = self.device.eco_temperature
|
||||
self._locked_temperature = self.device.locked_temperature
|
||||
self._min_temperature = self.device.min_temperature
|
||||
self._max_temperature = self.device.max_temperature
|
||||
self._is_locked = self.device.is_locked
|
||||
if self.device.temperature_scale == 'C':
|
||||
self._temperature_scale = TEMP_CELSIUS
|
||||
else:
|
||||
self._temperature_scale = TEMP_FAHRENHEIT
|
||||
|
||||
@@ -7,7 +7,8 @@ https://home-assistant.io/components/climate.proliphix/
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA)
|
||||
PRECISION_TENTHS, STATE_COOL, STATE_HEAT, STATE_IDLE,
|
||||
ClimateDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT, ATTR_TEMPERATURE)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
@@ -60,6 +61,15 @@ class ProliphixThermostat(ClimateDevice):
|
||||
"""Return the name of the thermostat."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
"""Return the precision of the system.
|
||||
|
||||
Proliphix temperature values are passed back and forth in the
|
||||
API as tenths of degrees F (i.e. 690 for 69 degrees).
|
||||
"""
|
||||
return PRECISION_TENTHS
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device specific state attributes."""
|
||||
|
||||
@@ -23,10 +23,19 @@ ATTR_FAN = 'fan'
|
||||
ATTR_MODE = 'mode'
|
||||
|
||||
CONF_HOLD_TEMP = 'hold_temp'
|
||||
CONF_AWAY_TEMPERATURE_HEAT = 'away_temperature_heat'
|
||||
CONF_AWAY_TEMPERATURE_COOL = 'away_temperature_cool'
|
||||
|
||||
DEFAULT_AWAY_TEMPERATURE_HEAT = 60
|
||||
DEFAULT_AWAY_TEMPERATURE_COOL = 85
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean,
|
||||
vol.Optional(CONF_AWAY_TEMPERATURE_HEAT,
|
||||
default=DEFAULT_AWAY_TEMPERATURE_HEAT): vol.Coerce(float),
|
||||
vol.Optional(CONF_AWAY_TEMPERATURE_COOL,
|
||||
default=DEFAULT_AWAY_TEMPERATURE_COOL): vol.Coerce(float),
|
||||
})
|
||||
|
||||
|
||||
@@ -45,12 +54,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
return False
|
||||
|
||||
hold_temp = config.get(CONF_HOLD_TEMP)
|
||||
away_temps = [
|
||||
config.get(CONF_AWAY_TEMPERATURE_HEAT),
|
||||
config.get(CONF_AWAY_TEMPERATURE_COOL)
|
||||
]
|
||||
tstats = []
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
tstat = radiotherm.get_thermostat(host)
|
||||
tstats.append(RadioThermostat(tstat, hold_temp))
|
||||
tstats.append(RadioThermostat(tstat, hold_temp, away_temps))
|
||||
except OSError:
|
||||
_LOGGER.exception("Unable to connect to Radio Thermostat: %s",
|
||||
host)
|
||||
@@ -61,7 +74,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
class RadioThermostat(ClimateDevice):
|
||||
"""Representation of a Radio Thermostat."""
|
||||
|
||||
def __init__(self, device, hold_temp):
|
||||
def __init__(self, device, hold_temp, away_temps):
|
||||
"""Initialize the thermostat."""
|
||||
self.device = device
|
||||
self.set_time()
|
||||
@@ -71,7 +84,10 @@ class RadioThermostat(ClimateDevice):
|
||||
self._name = None
|
||||
self._fmode = None
|
||||
self._tmode = None
|
||||
self.hold_temp = hold_temp
|
||||
self._hold_temp = hold_temp
|
||||
self._away = False
|
||||
self._away_temps = away_temps
|
||||
self._prev_temp = None
|
||||
self.update()
|
||||
self._operation_list = [STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_OFF]
|
||||
|
||||
@@ -113,6 +129,11 @@ class RadioThermostat(ClimateDevice):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
|
||||
@property
|
||||
def is_away_mode_on(self):
|
||||
"""Return true if away mode is on."""
|
||||
return self._away
|
||||
|
||||
def update(self):
|
||||
"""Update the data from the thermostat."""
|
||||
self._current_temperature = self.device.temp['raw']
|
||||
@@ -138,7 +159,7 @@ class RadioThermostat(ClimateDevice):
|
||||
self.device.t_cool = round(temperature * 2.0) / 2.0
|
||||
elif self._current_operation == STATE_HEAT:
|
||||
self.device.t_heat = round(temperature * 2.0) / 2.0
|
||||
if self.hold_temp:
|
||||
if self._hold_temp or self._away:
|
||||
self.device.hold = 1
|
||||
else:
|
||||
self.device.hold = 0
|
||||
@@ -162,3 +183,23 @@ class RadioThermostat(ClimateDevice):
|
||||
self.device.t_cool = round(self._target_temperature * 2.0) / 2.0
|
||||
elif operation_mode == STATE_HEAT:
|
||||
self.device.t_heat = round(self._target_temperature * 2.0) / 2.0
|
||||
|
||||
def turn_away_mode_on(self):
|
||||
"""Turn away on.
|
||||
|
||||
The RTCOA app simulates away mode by using a hold.
|
||||
"""
|
||||
away_temp = None
|
||||
if not self._away:
|
||||
self._prev_temp = self._target_temperature
|
||||
if self._current_operation == STATE_HEAT:
|
||||
away_temp = self._away_temps[0]
|
||||
elif self._current_operation == STATE_COOL:
|
||||
away_temp = self._away_temps[1]
|
||||
self._away = True
|
||||
self.set_temperature(temperature=away_temp)
|
||||
|
||||
def turn_away_mode_off(self):
|
||||
"""Turn away off."""
|
||||
self._away = False
|
||||
self.set_temperature(temperature=self._prev_temp)
|
||||
|
||||
@@ -30,7 +30,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the Wink thermostat."""
|
||||
import pywink
|
||||
temp_unit = hass.config.units.temperature_unit
|
||||
add_devices(WinkThermostat(thermostat, temp_unit)
|
||||
add_devices(WinkThermostat(thermostat, hass, temp_unit)
|
||||
for thermostat in pywink.get_thermostats())
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
class WinkThermostat(WinkDevice, ClimateDevice):
|
||||
"""Representation of a Wink thermostat."""
|
||||
|
||||
def __init__(self, wink, temp_unit):
|
||||
def __init__(self, wink, hass, temp_unit):
|
||||
"""Initialize the Wink device."""
|
||||
super().__init__(wink)
|
||||
super().__init__(wink, hass)
|
||||
wink = get_component('wink')
|
||||
self._config_temp_unit = temp_unit
|
||||
|
||||
|
||||
@@ -135,12 +135,19 @@ def setup(hass, config):
|
||||
params = service.data.copy()
|
||||
params.pop(ATTR_ENTITY_ID, None)
|
||||
|
||||
if method:
|
||||
for cover in component.extract_from_service(service):
|
||||
getattr(cover, method['method'])(**params)
|
||||
if not method:
|
||||
return
|
||||
|
||||
if cover.should_poll:
|
||||
cover.update_ha_state(True)
|
||||
covers = component.extract_from_service(service)
|
||||
|
||||
for cover in covers:
|
||||
getattr(cover, method['method'])(**params)
|
||||
|
||||
for cover in covers:
|
||||
if not cover.should_poll:
|
||||
continue
|
||||
|
||||
cover.update_ha_state(True)
|
||||
|
||||
descriptions = load_yaml_config_file(
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
@@ -12,7 +12,8 @@ import logging
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.components.cover import CoverDevice,\
|
||||
ATTR_POSITION
|
||||
import homeassistant.components.homematic as homematic
|
||||
from homeassistant.components.homematic import HMDevice
|
||||
from homeassistant.loader import get_component
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,14 +25,16 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None):
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
homematic = get_component("homematic")
|
||||
return homematic.setup_hmdevice_discovery_helper(
|
||||
hass,
|
||||
HMCover,
|
||||
discovery_info,
|
||||
add_callback_devices
|
||||
)
|
||||
|
||||
|
||||
class HMCover(homematic.HMDevice, CoverDevice):
|
||||
class HMCover(HMDevice, CoverDevice):
|
||||
"""Represents a Homematic Cover in Home Assistant."""
|
||||
|
||||
@property
|
||||
|
||||
46
homeassistant/components/cover/tellduslive.py
Normal file
46
homeassistant/components/cover/tellduslive.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Support for Tellstick covers 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/cover.tellduslive/
|
||||
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.cover import CoverDevice
|
||||
from homeassistant.components.tellduslive import TelldusLiveEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup covers."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
add_devices(TelldusLiveCover(hass, cover) for cover in discovery_info)
|
||||
|
||||
|
||||
class TelldusLiveCover(TelldusLiveEntity, CoverDevice):
|
||||
"""Representation of a cover."""
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return the current position of the cover."""
|
||||
return self.device.is_down
|
||||
|
||||
def close_cover(self, **kwargs):
|
||||
"""Close the cover."""
|
||||
self.device.down()
|
||||
self.changed()
|
||||
|
||||
def open_cover(self, **kwargs):
|
||||
"""Open the cover."""
|
||||
self.device.up()
|
||||
self.changed()
|
||||
|
||||
def stop_cover(self, **kwargs):
|
||||
"""Stop the cover."""
|
||||
self.device.stop()
|
||||
self.changed()
|
||||
@@ -15,18 +15,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the Wink cover platform."""
|
||||
import pywink
|
||||
|
||||
add_devices(WinkCoverDevice(shade) for shade in
|
||||
add_devices(WinkCoverDevice(shade, hass) for shade in
|
||||
pywink.get_shades())
|
||||
add_devices(WinkCoverDevice(door) for door in
|
||||
add_devices(WinkCoverDevice(door, hass) for door in
|
||||
pywink.get_garage_doors())
|
||||
|
||||
|
||||
class WinkCoverDevice(WinkDevice, CoverDevice):
|
||||
"""Representation of a Wink cover device."""
|
||||
|
||||
def __init__(self, wink):
|
||||
def __init__(self, wink, hass):
|
||||
"""Initialize the cover."""
|
||||
WinkDevice.__init__(self, wink)
|
||||
WinkDevice.__init__(self, wink, hass)
|
||||
|
||||
def close_cover(self):
|
||||
"""Close the shade."""
|
||||
|
||||
@@ -86,16 +86,11 @@ def setup(hass, config):
|
||||
group.Group.create_group(hass, 'people', [
|
||||
'device_tracker.demo_anne_therese', 'device_tracker.demo_home_boy',
|
||||
'device_tracker.demo_paulus'])
|
||||
group.Group.create_group(hass, 'thermostats', [
|
||||
'thermostat.nest', 'thermostat.thermostat'])
|
||||
group.Group.create_group(hass, 'downstairs', [
|
||||
'group.living_room', 'group.kitchen',
|
||||
'scene.romantic_lights', 'rollershutter.kitchen_window',
|
||||
'rollershutter.living_room_window', 'group.doors',
|
||||
'thermostat.nest',
|
||||
], view=True)
|
||||
group.Group.create_group(hass, 'Upstairs', [
|
||||
'thermostat.thermostat', 'group.bedroom',
|
||||
'thermostat.ecobee',
|
||||
], view=True)
|
||||
|
||||
# Setup scripts
|
||||
|
||||
@@ -10,6 +10,8 @@ import logging
|
||||
import os
|
||||
from typing import Any, Sequence, Callable
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.bootstrap import (
|
||||
@@ -19,6 +21,7 @@ from homeassistant.components import group, zone
|
||||
from homeassistant.components.discovery import SERVICE_NETGEAR
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers import config_per_platform, discovery
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType
|
||||
@@ -278,6 +281,9 @@ class DeviceTracker(object):
|
||||
yield from self.group.async_update_tracked_entity_ids(
|
||||
list(self.group.tracking) + [device.entity_id])
|
||||
|
||||
# lookup mac vendor string to be stored in config
|
||||
yield from device.set_vendor_for_mac()
|
||||
|
||||
# update known_devices.yaml
|
||||
self.hass.async_add_job(
|
||||
self.async_update_config(self.hass.config.path(YAML_DEVICES),
|
||||
@@ -291,7 +297,7 @@ class DeviceTracker(object):
|
||||
This method is a coroutine.
|
||||
"""
|
||||
with (yield from self._is_updating):
|
||||
self.hass.loop.run_in_executor(
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
None, update_config, self.hass.config.path(YAML_DEVICES),
|
||||
dev_id, device)
|
||||
|
||||
@@ -328,6 +334,7 @@ class Device(Entity):
|
||||
last_seen = None # type: dt_util.dt.datetime
|
||||
battery = None # type: str
|
||||
attributes = None # type: dict
|
||||
vendor = None # type: str
|
||||
|
||||
# Track if the last update of this device was HOME.
|
||||
last_update_home = False
|
||||
@@ -336,7 +343,7 @@ class Device(Entity):
|
||||
def __init__(self, hass: HomeAssistantType, consider_home: timedelta,
|
||||
track: bool, dev_id: str, mac: str, name: str=None,
|
||||
picture: str=None, gravatar: str=None,
|
||||
hide_if_away: bool=False) -> None:
|
||||
hide_if_away: bool=False, vendor: str=None) -> None:
|
||||
"""Initialize a device."""
|
||||
self.hass = hass
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(dev_id)
|
||||
@@ -362,6 +369,8 @@ class Device(Entity):
|
||||
self.config_picture = picture
|
||||
|
||||
self.away_hide = hide_if_away
|
||||
self.vendor = vendor
|
||||
self._attributes = {}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -391,12 +400,13 @@ class Device(Entity):
|
||||
if self.battery:
|
||||
attr[ATTR_BATTERY] = self.battery
|
||||
|
||||
if self.attributes:
|
||||
for key, value in self.attributes.items():
|
||||
attr[key] = value
|
||||
|
||||
return attr
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return device state attributes."""
|
||||
return self._attributes
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
"""If device should be hidden."""
|
||||
@@ -411,8 +421,11 @@ class Device(Entity):
|
||||
self.host_name = host_name
|
||||
self.location_name = location_name
|
||||
self.gps_accuracy = gps_accuracy or 0
|
||||
self.battery = battery
|
||||
self.attributes = attributes
|
||||
if battery:
|
||||
self.battery = battery
|
||||
if attributes:
|
||||
self._attributes.update(attributes)
|
||||
|
||||
self.gps = None
|
||||
|
||||
if gps is not None:
|
||||
@@ -460,6 +473,53 @@ class Device(Entity):
|
||||
self._state = STATE_HOME
|
||||
self.last_update_home = True
|
||||
|
||||
@asyncio.coroutine
|
||||
def set_vendor_for_mac(self):
|
||||
"""Set vendor string using api.macvendors.com."""
|
||||
self.vendor = yield from self.get_vendor_for_mac()
|
||||
|
||||
@asyncio.coroutine
|
||||
def get_vendor_for_mac(self):
|
||||
"""Try to find the vendor string for a given MAC address."""
|
||||
# can't continue without a mac
|
||||
if not self.mac:
|
||||
return None
|
||||
|
||||
# prevent lookup of invalid macs
|
||||
if not len(self.mac.split(':')) == 6:
|
||||
return 'unknown'
|
||||
|
||||
# we only need the first 3 bytes of the mac for a lookup
|
||||
# this improves somewhat on privacy
|
||||
oui_bytes = self.mac.split(':')[0:3]
|
||||
# bytes like 00 get truncates to 0, API needs full bytes
|
||||
oui = '{:02x}:{:02x}:{:02x}'.format(*[int(b, 16) for b in oui_bytes])
|
||||
url = 'http://api.macvendors.com/' + oui
|
||||
resp = None
|
||||
try:
|
||||
websession = async_get_clientsession(self.hass)
|
||||
|
||||
with async_timeout.timeout(5, loop=self.hass.loop):
|
||||
resp = yield from websession.get(url)
|
||||
# mac vendor found, response is the string
|
||||
if resp.status == 200:
|
||||
vendor_string = yield from resp.text()
|
||||
return vendor_string
|
||||
# if vendor is not known to the API (404) or there
|
||||
# was a failure during the lookup (500); set vendor
|
||||
# to something other then None to prevent retry
|
||||
# as the value is only relevant when it is to be stored
|
||||
# in the 'known_devices.yaml' file which only happens
|
||||
# the first time the device is seen.
|
||||
return 'unknown'
|
||||
except (asyncio.TimeoutError, aiohttp.errors.ClientError,
|
||||
aiohttp.errors.ClientDisconnectedError):
|
||||
# same as above
|
||||
return 'unknown'
|
||||
finally:
|
||||
if resp is not None:
|
||||
yield from resp.release()
|
||||
|
||||
|
||||
def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta):
|
||||
"""Load devices from YAML configuration file."""
|
||||
@@ -483,7 +543,8 @@ def async_load_config(path: str, hass: HomeAssistantType,
|
||||
vol.Optional('gravatar', default=None): vol.Any(None, cv.string),
|
||||
vol.Optional('picture', default=None): vol.Any(None, cv.string),
|
||||
vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All(
|
||||
cv.time_period, cv.positive_timedelta)
|
||||
cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional('vendor', default=None): vol.Any(None, cv.string),
|
||||
})
|
||||
try:
|
||||
result = []
|
||||
@@ -530,7 +591,7 @@ def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
else:
|
||||
host_name = scanner.get_device_name(mac)
|
||||
seen.add(mac)
|
||||
hass.async_add_job(async_see_device(mac=mac, host_name=host_name))
|
||||
hass.add_job(async_see_device(mac=mac, host_name=host_name))
|
||||
|
||||
async_track_utc_time_change(
|
||||
hass, device_tracker_scan, second=range(0, 60, interval))
|
||||
@@ -546,7 +607,8 @@ def update_config(path: str, dev_id: str, device: Device):
|
||||
'mac': device.mac,
|
||||
'picture': device.config_picture,
|
||||
'track': device.track,
|
||||
CONF_AWAY_HIDE: device.away_hide
|
||||
CONF_AWAY_HIDE: device.away_hide,
|
||||
'vendor': device.vendor,
|
||||
}}
|
||||
out.write('\n')
|
||||
out.write(dump(device))
|
||||
|
||||
@@ -286,8 +286,10 @@ class AsusWrtDeviceScanner(object):
|
||||
|
||||
# match mac addresses to IP addresses in ARP table
|
||||
for arp in result.arp:
|
||||
if match.group('mac').lower() in arp.decode('utf-8'):
|
||||
arp_match = _ARP_REGEX.search(arp.decode('utf-8'))
|
||||
if match.group('mac').lower() in \
|
||||
arp.decode('utf-8').lower():
|
||||
arp_match = _ARP_REGEX.search(
|
||||
arp.decode('utf-8').lower())
|
||||
if not arp_match:
|
||||
_LOGGER.warning('Could not parse arp row: %s', arp)
|
||||
continue
|
||||
|
||||
85
homeassistant/components/device_tracker/gpslogger.py
Normal file
85
homeassistant/components/device_tracker/gpslogger.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Support for the GPSLogger platform.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.gpslogger/
|
||||
"""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
# pylint: disable=unused-import
|
||||
from homeassistant.components.device_tracker import ( # NOQA
|
||||
DOMAIN, PLATFORM_SCHEMA)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
"""Setup an endpoint for the GPSLogger application."""
|
||||
hass.http.register_view(GPSLoggerView(see))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class GPSLoggerView(HomeAssistantView):
|
||||
"""View to handle gpslogger requests."""
|
||||
|
||||
url = '/api/gpslogger'
|
||||
name = 'api:gpslogger'
|
||||
|
||||
def __init__(self, see):
|
||||
"""Initialize GPSLogger url endpoints."""
|
||||
self.see = see
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
"""A GPSLogger message received as GET."""
|
||||
res = yield from self._handle(request.app['hass'], request.GET)
|
||||
return res
|
||||
|
||||
@asyncio.coroutine
|
||||
def _handle(self, hass, data):
|
||||
"""Handle gpslogger request."""
|
||||
if 'latitude' not in data or 'longitude' not in data:
|
||||
return ('Latitude and longitude not specified.',
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
if 'device' not in data:
|
||||
_LOGGER.error('Device id not specified.')
|
||||
return ('Device id not specified.',
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
device = data['device'].replace('-', '')
|
||||
gps_location = (data['latitude'], data['longitude'])
|
||||
accuracy = 200
|
||||
battery = -1
|
||||
|
||||
if 'accuracy' in data:
|
||||
accuracy = int(float(data['accuracy']))
|
||||
if 'battery' in data:
|
||||
battery = float(data['battery'])
|
||||
|
||||
attrs = {}
|
||||
if 'speed' in data:
|
||||
attrs['speed'] = float(data['speed'])
|
||||
if 'direction' in data:
|
||||
attrs['direction'] = float(data['direction'])
|
||||
if 'altitude' in data:
|
||||
attrs['altitude'] = float(data['altitude'])
|
||||
if 'provider' in data:
|
||||
attrs['provider'] = data['provider']
|
||||
if 'activity' in data:
|
||||
attrs['activity'] = data['activity']
|
||||
|
||||
yield from hass.loop.run_in_executor(
|
||||
None, partial(self.see, dev_id=device,
|
||||
gps=gps_location, battery=battery,
|
||||
gps_accuracy=accuracy,
|
||||
attributes=attrs))
|
||||
|
||||
return 'Setting location for {}'.format(device)
|
||||
@@ -23,7 +23,7 @@ DEPENDENCIES = ['http']
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
"""Setup an endpoint for the Locative application."""
|
||||
hass.http.register_view(LocativeView(hass, see))
|
||||
hass.http.register_view(LocativeView(see))
|
||||
|
||||
return True
|
||||
|
||||
@@ -34,27 +34,26 @@ class LocativeView(HomeAssistantView):
|
||||
url = '/api/locative'
|
||||
name = 'api:locative'
|
||||
|
||||
def __init__(self, hass, see):
|
||||
def __init__(self, see):
|
||||
"""Initialize Locative url endpoints."""
|
||||
super().__init__(hass)
|
||||
self.see = see
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
"""Locative message received as GET."""
|
||||
res = yield from self._handle(request.GET)
|
||||
res = yield from self._handle(request.app['hass'], request.GET)
|
||||
return res
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Locative message received."""
|
||||
data = yield from request.post()
|
||||
res = yield from self._handle(data)
|
||||
res = yield from self._handle(request.app['hass'], data)
|
||||
return res
|
||||
|
||||
@asyncio.coroutine
|
||||
# pylint: disable=too-many-return-statements
|
||||
def _handle(self, data):
|
||||
def _handle(self, hass, data):
|
||||
"""Handle locative request."""
|
||||
if 'latitude' not in data or 'longitude' not in data:
|
||||
return ('Latitude and longitude not specified.',
|
||||
@@ -81,19 +80,19 @@ class LocativeView(HomeAssistantView):
|
||||
gps_location = (data[ATTR_LATITUDE], data[ATTR_LONGITUDE])
|
||||
|
||||
if direction == 'enter':
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
yield from hass.loop.run_in_executor(
|
||||
None, partial(self.see, dev_id=device,
|
||||
location_name=location_name,
|
||||
gps=gps_location))
|
||||
return 'Setting location to {}'.format(location_name)
|
||||
|
||||
elif direction == 'exit':
|
||||
current_state = self.hass.states.get(
|
||||
current_state = hass.states.get(
|
||||
'{}.{}'.format(DOMAIN, device))
|
||||
|
||||
if current_state is None or current_state.state == location_name:
|
||||
location_name = STATE_NOT_HOME
|
||||
yield from self.hass.loop.run_in_executor(
|
||||
yield from hass.loop.run_in_executor(
|
||||
None, partial(self.see, dev_id=device,
|
||||
location_name=location_name,
|
||||
gps=gps_location))
|
||||
|
||||
@@ -25,6 +25,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
CONF_EXCLUDE = 'exclude'
|
||||
# Interval in minutes to exclude devices from a scan while they are home
|
||||
CONF_HOME_INTERVAL = 'home_interval'
|
||||
CONF_OPTIONS = 'scan_options'
|
||||
DEFAULT_OPTIONS = '-F --host-timeout 5s'
|
||||
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
@@ -33,7 +35,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOSTS): cv.ensure_list,
|
||||
vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int,
|
||||
vol.Optional(CONF_EXCLUDE, default=[]):
|
||||
vol.All(cv.ensure_list, vol.Length(min=1))
|
||||
vol.All(cv.ensure_list, vol.Length(min=1)),
|
||||
vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS):
|
||||
cv.string
|
||||
})
|
||||
|
||||
|
||||
@@ -69,8 +73,9 @@ class NmapDeviceScanner(object):
|
||||
self.last_results = []
|
||||
|
||||
self.hosts = config[CONF_HOSTS]
|
||||
self.exclude = config.get(CONF_EXCLUDE, [])
|
||||
self.exclude = config[CONF_EXCLUDE]
|
||||
minutes = config[CONF_HOME_INTERVAL]
|
||||
self._options = config[CONF_OPTIONS]
|
||||
self.home_interval = timedelta(minutes=minutes)
|
||||
|
||||
self.success_init = self._update_info()
|
||||
@@ -103,7 +108,7 @@ class NmapDeviceScanner(object):
|
||||
from nmap import PortScanner, PortScannerError
|
||||
scanner = PortScanner()
|
||||
|
||||
options = '-F --host-timeout 5s '
|
||||
options = self._options
|
||||
|
||||
if self.home_interval:
|
||||
boundary = dt_util.now() - self.home_interval
|
||||
|
||||
@@ -147,7 +147,7 @@ def setup_scanner(hass, config, see):
|
||||
data_type, max_gps_accuracy, payload)
|
||||
return None
|
||||
if convert(data.get('acc'), float, 1.0) == 0.0:
|
||||
_LOGGER.warning('Ignoring %s update because GPS accuracy'
|
||||
_LOGGER.warning('Ignoring %s update because GPS accuracy '
|
||||
'is zero: %s',
|
||||
data_type, payload)
|
||||
return None
|
||||
|
||||
@@ -9,16 +9,20 @@ import urllib
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
|
||||
# Unifi package doesn't list urllib3 as a requirement
|
||||
REQUIREMENTS = ['urllib3', 'unifi==1.2.5']
|
||||
REQUIREMENTS = ['urllib3', 'pyunifi==1.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CONF_PORT = 'port'
|
||||
CONF_SITE_ID = 'site_id'
|
||||
|
||||
NOTIFICATION_ID = 'unifi_notification'
|
||||
NOTIFICATION_TITLE = 'Unifi Device Tracker Setup'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_HOST, default='localhost'): cv.string,
|
||||
vol.Optional(CONF_SITE_ID, default='default'): cv.string,
|
||||
@@ -30,7 +34,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
|
||||
def get_scanner(hass, config):
|
||||
"""Setup Unifi device_tracker."""
|
||||
from unifi.controller import Controller
|
||||
from pyunifi.controller import Controller
|
||||
|
||||
host = config[DOMAIN].get(CONF_HOST)
|
||||
username = config[DOMAIN].get(CONF_USERNAME)
|
||||
@@ -38,10 +42,18 @@ def get_scanner(hass, config):
|
||||
site_id = config[DOMAIN].get(CONF_SITE_ID)
|
||||
port = config[DOMAIN].get(CONF_PORT)
|
||||
|
||||
persistent_notification = loader.get_component('persistent_notification')
|
||||
try:
|
||||
ctrl = Controller(host, username, password, port, 'v4', site_id)
|
||||
except urllib.error.HTTPError as ex:
|
||||
_LOGGER.error('Failed to connect to unifi: %s', ex)
|
||||
_LOGGER.error('Failed to connect to Unifi: %s', ex)
|
||||
persistent_notification.create(
|
||||
hass, 'Failed to connect to Unifi. '
|
||||
'Error: {}<br />'
|
||||
'You will need to restart hass after fixing.'
|
||||
''.format(ex),
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID)
|
||||
return False
|
||||
|
||||
return UnifiScanner(ctrl)
|
||||
|
||||
@@ -14,7 +14,7 @@ import voluptuous as vol
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START
|
||||
from homeassistant.helpers.discovery import load_platform, discover
|
||||
|
||||
REQUIREMENTS = ['netdisco==0.7.6']
|
||||
REQUIREMENTS = ['netdisco==0.8.1']
|
||||
|
||||
DOMAIN = 'discovery'
|
||||
|
||||
@@ -36,6 +36,8 @@ SERVICE_HANDLERS = {
|
||||
'yamaha': ('media_player', 'yamaha'),
|
||||
'logitech_mediaserver': ('media_player', 'squeezebox'),
|
||||
'directv': ('media_player', 'directv'),
|
||||
'denonavr': ('media_player', 'denonavr'),
|
||||
'samsung_tv': ('media_player', 'samsungtv'),
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
|
||||
@@ -1,565 +0,0 @@
|
||||
"""
|
||||
Support for local control of entities by emulating the Phillips Hue bridge.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/emulated_hue/
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
import socket
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import util, core
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
|
||||
STATE_ON, STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS
|
||||
)
|
||||
from homeassistant.components.http import (
|
||||
HomeAssistantView, HomeAssistantWSGI
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DOMAIN = 'emulated_hue'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_HOST_IP = 'host_ip'
|
||||
CONF_LISTEN_PORT = 'listen_port'
|
||||
CONF_OFF_MAPS_TO_ON_DOMAINS = 'off_maps_to_on_domains'
|
||||
CONF_EXPOSE_BY_DEFAULT = 'expose_by_default'
|
||||
CONF_EXPOSED_DOMAINS = 'exposed_domains'
|
||||
|
||||
ATTR_EMULATED_HUE = 'emulated_hue'
|
||||
ATTR_EMULATED_HUE_NAME = 'emulated_hue_name'
|
||||
|
||||
DEFAULT_LISTEN_PORT = 8300
|
||||
DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ['script', 'scene']
|
||||
DEFAULT_EXPOSE_BY_DEFAULT = True
|
||||
DEFAULT_EXPOSED_DOMAINS = [
|
||||
'switch', 'light', 'group', 'input_boolean', 'media_player', 'fan'
|
||||
]
|
||||
|
||||
HUE_API_STATE_ON = 'on'
|
||||
HUE_API_STATE_BRI = 'bri'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_HOST_IP): cv.string,
|
||||
vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
|
||||
vol.Optional(CONF_OFF_MAPS_TO_ON_DOMAINS): cv.ensure_list,
|
||||
vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean,
|
||||
vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def setup(hass, yaml_config):
|
||||
"""Activate the emulated_hue component."""
|
||||
config = Config(yaml_config)
|
||||
|
||||
server = HomeAssistantWSGI(
|
||||
hass,
|
||||
development=False,
|
||||
server_host=config.host_ip_addr,
|
||||
server_port=config.listen_port,
|
||||
api_password=None,
|
||||
ssl_certificate=None,
|
||||
ssl_key=None,
|
||||
cors_origins=[],
|
||||
use_x_forwarded_for=False,
|
||||
trusted_networks=[]
|
||||
)
|
||||
|
||||
server.register_view(DescriptionXmlView(hass, config))
|
||||
server.register_view(HueUsernameView(hass))
|
||||
server.register_view(HueLightsView(hass, config))
|
||||
|
||||
upnp_listener = UPNPResponderThread(
|
||||
config.host_ip_addr, config.listen_port)
|
||||
|
||||
@asyncio.coroutine
|
||||
def stop_emulated_hue_bridge(event):
|
||||
"""Stop the emulated hue bridge."""
|
||||
upnp_listener.stop()
|
||||
yield from server.stop()
|
||||
|
||||
@asyncio.coroutine
|
||||
def start_emulated_hue_bridge(event):
|
||||
"""Start the emulated hue bridge."""
|
||||
upnp_listener.start()
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
|
||||
stop_emulated_hue_bridge)
|
||||
yield from server.start()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Config(object):
|
||||
"""Holds configuration variables for the emulated hue bridge."""
|
||||
|
||||
def __init__(self, yaml_config):
|
||||
"""Initialize the instance."""
|
||||
conf = yaml_config.get(DOMAIN, {})
|
||||
|
||||
# Get the IP address that will be passed to the Echo during discovery
|
||||
self.host_ip_addr = conf.get(CONF_HOST_IP)
|
||||
if self.host_ip_addr is None:
|
||||
self.host_ip_addr = util.get_local_ip()
|
||||
_LOGGER.warning(
|
||||
"Listen IP address not specified, auto-detected address is %s",
|
||||
self.host_ip_addr)
|
||||
|
||||
# Get the port that the Hue bridge will listen on
|
||||
self.listen_port = conf.get(CONF_LISTEN_PORT)
|
||||
if not isinstance(self.listen_port, int):
|
||||
self.listen_port = DEFAULT_LISTEN_PORT
|
||||
_LOGGER.warning(
|
||||
"Listen port not specified, defaulting to %s",
|
||||
self.listen_port)
|
||||
|
||||
# Get domains that cause both "on" and "off" commands to map to "on"
|
||||
# This is primarily useful for things like scenes or scripts, which
|
||||
# don't really have a concept of being off
|
||||
self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
|
||||
if not isinstance(self.off_maps_to_on_domains, list):
|
||||
self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
|
||||
|
||||
# Get whether or not entities should be exposed by default, or if only
|
||||
# explicitly marked ones will be exposed
|
||||
self.expose_by_default = conf.get(
|
||||
CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT)
|
||||
|
||||
# Get domains that are exposed by default when expose_by_default is
|
||||
# True
|
||||
self.exposed_domains = conf.get(
|
||||
CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
|
||||
|
||||
|
||||
class DescriptionXmlView(HomeAssistantView):
|
||||
"""Handles requests for the description.xml file."""
|
||||
|
||||
url = '/description.xml'
|
||||
name = 'description:xml'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass, config):
|
||||
"""Initialize the instance of the view."""
|
||||
super().__init__(hass)
|
||||
self.config = config
|
||||
|
||||
@core.callback
|
||||
def get(self, request):
|
||||
"""Handle a GET request."""
|
||||
xml_template = """<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<URLBase>http://{0}:{1}/</URLBase>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
|
||||
<friendlyName>HASS Bridge ({0})</friendlyName>
|
||||
<manufacturer>Royal Philips Electronics</manufacturer>
|
||||
<manufacturerURL>http://www.philips.com</manufacturerURL>
|
||||
<modelDescription>Philips hue Personal Wireless Lighting</modelDescription>
|
||||
<modelName>Philips hue bridge 2015</modelName>
|
||||
<modelNumber>BSB002</modelNumber>
|
||||
<modelURL>http://www.meethue.com</modelURL>
|
||||
<serialNumber>1234</serialNumber>
|
||||
<UDN>uuid:2f402f80-da50-11e1-9b23-001788255acc</UDN>
|
||||
</device>
|
||||
</root>
|
||||
"""
|
||||
|
||||
resp_text = xml_template.format(
|
||||
self.config.host_ip_addr, self.config.listen_port)
|
||||
|
||||
return web.Response(text=resp_text, content_type='text/xml')
|
||||
|
||||
|
||||
class HueUsernameView(HomeAssistantView):
|
||||
"""Handle requests to create a username for the emulated hue bridge."""
|
||||
|
||||
url = '/api'
|
||||
name = 'hue:api'
|
||||
extra_urls = ['/api/']
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize the instance of the view."""
|
||||
super().__init__(hass)
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Handle a POST request."""
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
if 'devicetype' not in data:
|
||||
return self.json_message('devicetype not specified',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
return self.json([{'success': {'username': '12345678901234567890'}}])
|
||||
|
||||
|
||||
class HueLightsView(HomeAssistantView):
|
||||
"""Handle requests for getting and setting info about entities."""
|
||||
|
||||
url = '/api/{username}/lights'
|
||||
name = 'api:username:lights'
|
||||
extra_urls = ['/api/{username}/lights/{entity_id}',
|
||||
'/api/{username}/lights/{entity_id}/state']
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass, config):
|
||||
"""Initialize the instance of the view."""
|
||||
super().__init__(hass)
|
||||
self.config = config
|
||||
self.cached_states = {}
|
||||
|
||||
@core.callback
|
||||
def get(self, request, username, entity_id=None):
|
||||
"""Handle a GET request."""
|
||||
if entity_id is None:
|
||||
return self.async_get_lights_list()
|
||||
|
||||
if not request.path.endswith('state'):
|
||||
return self.async_get_light_state(entity_id)
|
||||
|
||||
return web.Response(text="Method not allowed", status=405)
|
||||
|
||||
@asyncio.coroutine
|
||||
def put(self, request, username, entity_id=None):
|
||||
"""Handle a PUT request."""
|
||||
if not request.path.endswith('state'):
|
||||
return web.Response(text="Method not allowed", status=405)
|
||||
|
||||
if entity_id and self.hass.states.get(entity_id) is None:
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
|
||||
try:
|
||||
json_data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
result = yield from self.async_put_light_state(json_data, entity_id)
|
||||
return result
|
||||
|
||||
@core.callback
|
||||
def async_get_lights_list(self):
|
||||
"""Process a request to get the list of available lights."""
|
||||
json_response = {}
|
||||
|
||||
for entity in self.hass.states.async_all():
|
||||
if self.is_entity_exposed(entity):
|
||||
json_response[entity.entity_id] = entity_to_json(entity)
|
||||
|
||||
return self.json(json_response)
|
||||
|
||||
@core.callback
|
||||
def async_get_light_state(self, entity_id):
|
||||
"""Process a request to get the state of an individual light."""
|
||||
entity = self.hass.states.get(entity_id)
|
||||
if entity is None or not self.is_entity_exposed(entity):
|
||||
return web.Response(text="Entity not found", status=404)
|
||||
|
||||
cached_state = self.cached_states.get(entity_id, None)
|
||||
|
||||
if cached_state is None:
|
||||
final_state = entity.state == STATE_ON
|
||||
final_brightness = entity.attributes.get(
|
||||
ATTR_BRIGHTNESS, 255 if final_state else 0)
|
||||
else:
|
||||
final_state, final_brightness = cached_state
|
||||
|
||||
json_response = entity_to_json(entity, final_state, final_brightness)
|
||||
|
||||
return self.json(json_response)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_put_light_state(self, request_json, entity_id):
|
||||
"""Process a request to set the state of an individual light."""
|
||||
config = self.config
|
||||
|
||||
# Retrieve the entity from the state machine
|
||||
entity = self.hass.states.get(entity_id)
|
||||
if entity is None:
|
||||
return web.Response(text="Entity not found", status=404)
|
||||
|
||||
if not self.is_entity_exposed(entity):
|
||||
return web.Response(text="Entity not found", status=404)
|
||||
|
||||
# Parse the request into requested "on" status and brightness
|
||||
parsed = parse_hue_api_put_light_body(request_json, entity)
|
||||
|
||||
if parsed is None:
|
||||
return web.Response(text="Bad request", status=400)
|
||||
|
||||
result, brightness = parsed
|
||||
|
||||
# Convert the resulting "on" status into the service we need to call
|
||||
service = SERVICE_TURN_ON if result else SERVICE_TURN_OFF
|
||||
|
||||
# Construct what we need to send to the service
|
||||
data = {ATTR_ENTITY_ID: entity_id}
|
||||
|
||||
# If the requested entity is a script add some variables
|
||||
if entity.domain.lower() == "script":
|
||||
data['variables'] = {
|
||||
'requested_state': STATE_ON if result else STATE_OFF
|
||||
}
|
||||
|
||||
if brightness is not None:
|
||||
data['variables']['requested_level'] = brightness
|
||||
|
||||
elif brightness is not None:
|
||||
data[ATTR_BRIGHTNESS] = brightness
|
||||
|
||||
if entity.domain.lower() in config.off_maps_to_on_domains:
|
||||
# Map the off command to on
|
||||
service = SERVICE_TURN_ON
|
||||
|
||||
# Caching is required because things like scripts and scenes won't
|
||||
# report as "off" to Alexa if an "off" command is received, because
|
||||
# they'll map to "on". Thus, instead of reporting its actual
|
||||
# status, we report what Alexa will want to see, which is the same
|
||||
# as the actual requested command.
|
||||
self.cached_states[entity_id] = (result, brightness)
|
||||
|
||||
# Perform the requested action
|
||||
yield from self.hass.services.async_call(core.DOMAIN, service, data,
|
||||
blocking=True)
|
||||
|
||||
json_response = \
|
||||
[create_hue_success_response(entity_id, HUE_API_STATE_ON, result)]
|
||||
|
||||
if brightness is not None:
|
||||
json_response.append(create_hue_success_response(
|
||||
entity_id, HUE_API_STATE_BRI, brightness))
|
||||
|
||||
return self.json(json_response)
|
||||
|
||||
def is_entity_exposed(self, entity):
|
||||
"""Determine if an entity should be exposed on the emulated bridge.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
config = self.config
|
||||
|
||||
if entity.attributes.get('view') is not None:
|
||||
# Ignore entities that are views
|
||||
return False
|
||||
|
||||
domain = entity.domain.lower()
|
||||
explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None)
|
||||
|
||||
domain_exposed_by_default = \
|
||||
config.expose_by_default and domain in config.exposed_domains
|
||||
|
||||
# Expose an entity if the entity's domain is exposed by default and
|
||||
# the configuration doesn't explicitly exclude it from being
|
||||
# exposed, or if the entity is explicitly exposed
|
||||
is_default_exposed = \
|
||||
domain_exposed_by_default and explicit_expose is not False
|
||||
|
||||
return is_default_exposed or explicit_expose
|
||||
|
||||
|
||||
def parse_hue_api_put_light_body(request_json, entity):
|
||||
"""Parse the body of a request to change the state of a light."""
|
||||
if HUE_API_STATE_ON in request_json:
|
||||
if not isinstance(request_json[HUE_API_STATE_ON], bool):
|
||||
return None
|
||||
|
||||
if request_json['on']:
|
||||
# Echo requested device be turned on
|
||||
brightness = None
|
||||
report_brightness = False
|
||||
result = True
|
||||
else:
|
||||
# Echo requested device be turned off
|
||||
brightness = None
|
||||
report_brightness = False
|
||||
result = False
|
||||
|
||||
if HUE_API_STATE_BRI in request_json:
|
||||
# Make sure the entity actually supports brightness
|
||||
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
|
||||
try:
|
||||
# Clamp brightness from 0 to 255
|
||||
brightness = \
|
||||
max(0, min(int(request_json[HUE_API_STATE_BRI]), 255))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
report_brightness = True
|
||||
result = (brightness > 0)
|
||||
elif entity.domain.lower() == "script":
|
||||
# Convert 0-255 to 0-100
|
||||
level = int(request_json[HUE_API_STATE_BRI]) / 255 * 100
|
||||
|
||||
brightness = round(level)
|
||||
report_brightness = True
|
||||
result = True
|
||||
|
||||
return (result, brightness) if report_brightness else (result, None)
|
||||
|
||||
|
||||
def entity_to_json(entity, is_on=None, brightness=None):
|
||||
"""Convert an entity to its Hue bridge JSON representation."""
|
||||
if is_on is None:
|
||||
is_on = entity.state == STATE_ON
|
||||
|
||||
if brightness is None:
|
||||
brightness = 255 if is_on else 0
|
||||
|
||||
name = entity.attributes.get(
|
||||
ATTR_EMULATED_HUE_NAME, entity.attributes[ATTR_FRIENDLY_NAME])
|
||||
|
||||
return {
|
||||
'state':
|
||||
{
|
||||
HUE_API_STATE_ON: is_on,
|
||||
HUE_API_STATE_BRI: brightness,
|
||||
'reachable': True
|
||||
},
|
||||
'type': 'Dimmable light',
|
||||
'name': name,
|
||||
'modelid': 'HASS123',
|
||||
'uniqueid': entity.entity_id,
|
||||
'swversion': '123'
|
||||
}
|
||||
|
||||
|
||||
def create_hue_success_response(entity_id, attr, value):
|
||||
"""Create a success response for an attribute set on a light."""
|
||||
success_key = '/lights/{}/state/{}'.format(entity_id, attr)
|
||||
return {'success': {success_key: value}}
|
||||
|
||||
|
||||
class UPNPResponderThread(threading.Thread):
|
||||
"""Handle responding to UPNP/SSDP discovery requests."""
|
||||
|
||||
_interrupted = False
|
||||
|
||||
def __init__(self, host_ip_addr, listen_port):
|
||||
"""Initialize the class."""
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
self.host_ip_addr = host_ip_addr
|
||||
self.listen_port = listen_port
|
||||
|
||||
# Note that the double newline at the end of
|
||||
# this string is required per the SSDP spec
|
||||
resp_template = """HTTP/1.1 200 OK
|
||||
CACHE-CONTROL: max-age=60
|
||||
EXT:
|
||||
LOCATION: http://{0}:{1}/description.xml
|
||||
SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1
|
||||
ST: urn:schemas-upnp-org:device:basic:1
|
||||
USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1
|
||||
|
||||
"""
|
||||
|
||||
self.upnp_response = resp_template.format(host_ip_addr, listen_port) \
|
||||
.replace("\n", "\r\n") \
|
||||
.encode('utf-8')
|
||||
|
||||
# Set up a pipe for signaling to the receiver that it's time to
|
||||
# shutdown. Essentially, we place the SSDP socket into nonblocking
|
||||
# mode and use select() to wait for data to arrive on either the SSDP
|
||||
# socket or the pipe. If data arrives on either one, select() returns
|
||||
# and tells us which filenos have data ready to read.
|
||||
#
|
||||
# When we want to stop the responder, we write data to the pipe, which
|
||||
# causes the select() to return and indicate that said pipe has data
|
||||
# ready to be read, which indicates to us that the responder needs to
|
||||
# be shutdown.
|
||||
self._interrupted_read_pipe, self._interrupted_write_pipe = os.pipe()
|
||||
|
||||
def run(self):
|
||||
"""Run the server."""
|
||||
# Listen for UDP port 1900 packets sent to SSDP multicast address
|
||||
ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
ssdp_socket.setblocking(False)
|
||||
|
||||
# Required for receiving multicast
|
||||
ssdp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
||||
ssdp_socket.setsockopt(
|
||||
socket.SOL_IP,
|
||||
socket.IP_MULTICAST_IF,
|
||||
socket.inet_aton(self.host_ip_addr))
|
||||
|
||||
ssdp_socket.setsockopt(
|
||||
socket.SOL_IP,
|
||||
socket.IP_ADD_MEMBERSHIP,
|
||||
socket.inet_aton("239.255.255.250") +
|
||||
socket.inet_aton(self.host_ip_addr))
|
||||
|
||||
ssdp_socket.bind(("239.255.255.250", 1900))
|
||||
|
||||
while True:
|
||||
if self._interrupted:
|
||||
clean_socket_close(ssdp_socket)
|
||||
return
|
||||
|
||||
try:
|
||||
read, _, _ = select.select(
|
||||
[self._interrupted_read_pipe, ssdp_socket], [],
|
||||
[ssdp_socket])
|
||||
|
||||
if self._interrupted_read_pipe in read:
|
||||
# Implies self._interrupted is True
|
||||
clean_socket_close(ssdp_socket)
|
||||
return
|
||||
elif ssdp_socket in read:
|
||||
data, addr = ssdp_socket.recvfrom(1024)
|
||||
else:
|
||||
continue
|
||||
except socket.error as ex:
|
||||
if self._interrupted:
|
||||
clean_socket_close(ssdp_socket)
|
||||
return
|
||||
|
||||
_LOGGER.error("UPNP Responder socket exception occured: %s",
|
||||
ex.__str__)
|
||||
|
||||
if "M-SEARCH" in data.decode('utf-8'):
|
||||
# SSDP M-SEARCH method received, respond to it with our info
|
||||
resp_socket = socket.socket(
|
||||
socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
resp_socket.sendto(self.upnp_response, addr)
|
||||
resp_socket.close()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the server."""
|
||||
# Request for server
|
||||
self._interrupted = True
|
||||
os.write(self._interrupted_write_pipe, bytes([0]))
|
||||
self.join()
|
||||
|
||||
|
||||
def clean_socket_close(sock):
|
||||
"""Close a socket connection and logs its closure."""
|
||||
_LOGGER.info("UPNP responder shutting down.")
|
||||
|
||||
sock.close()
|
||||
198
homeassistant/components/emulated_hue/__init__.py
Normal file
198
homeassistant/components/emulated_hue/__init__.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Support for local control of entities by emulating the Phillips Hue bridge.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/emulated_hue/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import util
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.components.http import HomeAssistantWSGI
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from .hue_api import (
|
||||
HueUsernameView, HueAllLightsStateView, HueOneLightStateView,
|
||||
HueOneLightChangeView)
|
||||
from .upnp import DescriptionXmlView, UPNPResponderThread
|
||||
|
||||
DOMAIN = 'emulated_hue'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_HOST_IP = 'host_ip'
|
||||
CONF_LISTEN_PORT = 'listen_port'
|
||||
CONF_OFF_MAPS_TO_ON_DOMAINS = 'off_maps_to_on_domains'
|
||||
CONF_EXPOSE_BY_DEFAULT = 'expose_by_default'
|
||||
CONF_EXPOSED_DOMAINS = 'exposed_domains'
|
||||
CONF_TYPE = 'type'
|
||||
|
||||
TYPE_ALEXA = 'alexa'
|
||||
TYPE_GOOGLE = 'google_home'
|
||||
|
||||
DEFAULT_LISTEN_PORT = 8300
|
||||
DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ['script', 'scene']
|
||||
DEFAULT_EXPOSE_BY_DEFAULT = True
|
||||
DEFAULT_EXPOSED_DOMAINS = [
|
||||
'switch', 'light', 'group', 'input_boolean', 'media_player', 'fan'
|
||||
]
|
||||
DEFAULT_TYPE = TYPE_ALEXA
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_HOST_IP): cv.string,
|
||||
vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
|
||||
vol.Optional(CONF_OFF_MAPS_TO_ON_DOMAINS): cv.ensure_list,
|
||||
vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean,
|
||||
vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list,
|
||||
vol.Optional(CONF_TYPE, default=DEFAULT_TYPE):
|
||||
vol.Any(TYPE_ALEXA, TYPE_GOOGLE)
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
ATTR_EMULATED_HUE = 'emulated_hue'
|
||||
|
||||
|
||||
def setup(hass, yaml_config):
|
||||
"""Activate the emulated_hue component."""
|
||||
config = Config(yaml_config.get(DOMAIN, {}))
|
||||
|
||||
server = HomeAssistantWSGI(
|
||||
hass,
|
||||
development=False,
|
||||
server_host=config.host_ip_addr,
|
||||
server_port=config.listen_port,
|
||||
api_password=None,
|
||||
ssl_certificate=None,
|
||||
ssl_key=None,
|
||||
cors_origins=None,
|
||||
use_x_forwarded_for=False,
|
||||
trusted_networks=[],
|
||||
login_threshold=0,
|
||||
is_ban_enabled=False
|
||||
)
|
||||
|
||||
server.register_view(DescriptionXmlView(config))
|
||||
server.register_view(HueUsernameView)
|
||||
server.register_view(HueAllLightsStateView(config))
|
||||
server.register_view(HueOneLightStateView(config))
|
||||
server.register_view(HueOneLightChangeView(config))
|
||||
|
||||
upnp_listener = UPNPResponderThread(
|
||||
config.host_ip_addr, config.listen_port)
|
||||
|
||||
@asyncio.coroutine
|
||||
def stop_emulated_hue_bridge(event):
|
||||
"""Stop the emulated hue bridge."""
|
||||
upnp_listener.stop()
|
||||
yield from server.stop()
|
||||
|
||||
@asyncio.coroutine
|
||||
def start_emulated_hue_bridge(event):
|
||||
"""Start the emulated hue bridge."""
|
||||
upnp_listener.start()
|
||||
yield from server.start()
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
|
||||
stop_emulated_hue_bridge)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Config(object):
|
||||
"""Holds configuration variables for the emulated hue bridge."""
|
||||
|
||||
def __init__(self, conf):
|
||||
"""Initialize the instance."""
|
||||
self.type = conf.get(CONF_TYPE)
|
||||
self.numbers = {}
|
||||
self.cached_states = {}
|
||||
|
||||
# Get the IP address that will be passed to the Echo during discovery
|
||||
self.host_ip_addr = conf.get(CONF_HOST_IP)
|
||||
if self.host_ip_addr is None:
|
||||
self.host_ip_addr = util.get_local_ip()
|
||||
_LOGGER.warning(
|
||||
"Listen IP address not specified, auto-detected address is %s",
|
||||
self.host_ip_addr)
|
||||
|
||||
# Get the port that the Hue bridge will listen on
|
||||
self.listen_port = conf.get(CONF_LISTEN_PORT)
|
||||
if not isinstance(self.listen_port, int):
|
||||
self.listen_port = DEFAULT_LISTEN_PORT
|
||||
_LOGGER.warning(
|
||||
"Listen port not specified, defaulting to %s",
|
||||
self.listen_port)
|
||||
|
||||
if self.type == TYPE_GOOGLE and self.listen_port != 80:
|
||||
_LOGGER.warning('When targetting Google Home, listening port has '
|
||||
'to be port 80')
|
||||
|
||||
# Get domains that cause both "on" and "off" commands to map to "on"
|
||||
# This is primarily useful for things like scenes or scripts, which
|
||||
# don't really have a concept of being off
|
||||
self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
|
||||
if not isinstance(self.off_maps_to_on_domains, list):
|
||||
self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
|
||||
|
||||
# Get whether or not entities should be exposed by default, or if only
|
||||
# explicitly marked ones will be exposed
|
||||
self.expose_by_default = conf.get(
|
||||
CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT)
|
||||
|
||||
# Get domains that are exposed by default when expose_by_default is
|
||||
# True
|
||||
self.exposed_domains = conf.get(
|
||||
CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
|
||||
|
||||
def entity_id_to_number(self, entity_id):
|
||||
"""Get a unique number for the entity id."""
|
||||
if self.type == TYPE_ALEXA:
|
||||
return entity_id
|
||||
|
||||
# Google Home
|
||||
for number, ent_id in self.numbers.items():
|
||||
if entity_id == ent_id:
|
||||
return number
|
||||
|
||||
number = str(len(self.numbers) + 1)
|
||||
self.numbers[number] = entity_id
|
||||
return number
|
||||
|
||||
def number_to_entity_id(self, number):
|
||||
"""Convert unique number to entity id."""
|
||||
if self.type == TYPE_ALEXA:
|
||||
return number
|
||||
|
||||
# Google Home
|
||||
assert isinstance(number, str)
|
||||
return self.numbers.get(number)
|
||||
|
||||
def is_entity_exposed(self, entity):
|
||||
"""Determine if an entity should be exposed on the emulated bridge.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
if entity.attributes.get('view') is not None:
|
||||
# Ignore entities that are views
|
||||
return False
|
||||
|
||||
domain = entity.domain.lower()
|
||||
explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None)
|
||||
|
||||
domain_exposed_by_default = \
|
||||
self.expose_by_default and domain in self.exposed_domains
|
||||
|
||||
# Expose an entity if the entity's domain is exposed by default and
|
||||
# the configuration doesn't explicitly exclude it from being
|
||||
# exposed, or if the entity is explicitly exposed
|
||||
is_default_exposed = \
|
||||
domain_exposed_by_default and explicit_expose is not False
|
||||
|
||||
return is_default_exposed or explicit_expose
|
||||
317
homeassistant/components/emulated_hue/hue_api.py
Normal file
317
homeassistant/components/emulated_hue/hue_api.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Provides a Hue API to control Home Assistant."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET,
|
||||
STATE_ON, STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS
|
||||
)
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_VOLUME_LEVEL, ATTR_SUPPORTED_MEDIA_COMMANDS,
|
||||
SUPPORT_VOLUME_SET,
|
||||
)
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_EMULATED_HUE = 'emulated_hue'
|
||||
ATTR_EMULATED_HUE_NAME = 'emulated_hue_name'
|
||||
|
||||
HUE_API_STATE_ON = 'on'
|
||||
HUE_API_STATE_BRI = 'bri'
|
||||
|
||||
|
||||
class HueUsernameView(HomeAssistantView):
|
||||
"""Handle requests to create a username for the emulated hue bridge."""
|
||||
|
||||
url = '/api'
|
||||
name = 'emulated_hue:api:create_username'
|
||||
extra_urls = ['/api/']
|
||||
requires_auth = False
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Handle a POST request."""
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
if 'devicetype' not in data:
|
||||
return self.json_message('devicetype not specified',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
return self.json([{'success': {'username': '12345678901234567890'}}])
|
||||
|
||||
|
||||
class HueAllLightsStateView(HomeAssistantView):
|
||||
"""Handle requests for getting and setting info about entities."""
|
||||
|
||||
url = '/api/{username}/lights'
|
||||
name = 'emulated_hue:lights:state'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize the instance of the view."""
|
||||
self.config = config
|
||||
|
||||
@core.callback
|
||||
def get(self, request, username):
|
||||
"""Process a request to get the list of available lights."""
|
||||
hass = request.app['hass']
|
||||
json_response = {}
|
||||
|
||||
for entity in hass.states.async_all():
|
||||
if self.config.is_entity_exposed(entity):
|
||||
state, brightness = get_entity_state(self.config, entity)
|
||||
|
||||
number = self.config.entity_id_to_number(entity.entity_id)
|
||||
json_response[number] = entity_to_json(
|
||||
entity, state, brightness)
|
||||
|
||||
return self.json(json_response)
|
||||
|
||||
|
||||
class HueOneLightStateView(HomeAssistantView):
|
||||
"""Handle requests for getting and setting info about entities."""
|
||||
|
||||
url = '/api/{username}/lights/{entity_id}'
|
||||
name = 'emulated_hue:light:state'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize the instance of the view."""
|
||||
self.config = config
|
||||
|
||||
@core.callback
|
||||
def get(self, request, username, entity_id=None):
|
||||
"""Process a request to get the state of an individual light."""
|
||||
hass = request.app['hass']
|
||||
entity_id = self.config.number_to_entity_id(entity_id)
|
||||
entity = hass.states.get(entity_id)
|
||||
|
||||
if entity is None:
|
||||
_LOGGER.error('Entity not found: %s', entity_id)
|
||||
return web.Response(text="Entity not found", status=404)
|
||||
|
||||
if not self.config.is_entity_exposed(entity):
|
||||
_LOGGER.error('Entity not exposed: %s', entity_id)
|
||||
return web.Response(text="Entity not exposed", status=404)
|
||||
|
||||
state, brightness = get_entity_state(self.config, entity)
|
||||
|
||||
json_response = entity_to_json(entity, state, brightness)
|
||||
|
||||
return self.json(json_response)
|
||||
|
||||
|
||||
class HueOneLightChangeView(HomeAssistantView):
|
||||
"""Handle requests for getting and setting info about entities."""
|
||||
|
||||
url = '/api/{username}/lights/{entity_number}/state'
|
||||
name = 'emulated_hue:light:state'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize the instance of the view."""
|
||||
self.config = config
|
||||
|
||||
@asyncio.coroutine
|
||||
def put(self, request, username, entity_number):
|
||||
"""Process a request to set the state of an individual light."""
|
||||
config = self.config
|
||||
hass = request.app['hass']
|
||||
entity_id = config.number_to_entity_id(entity_number)
|
||||
|
||||
if entity_id is None:
|
||||
_LOGGER.error('Unknown entity number: %s', entity_number)
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
|
||||
entity = hass.states.get(entity_id)
|
||||
|
||||
if entity is None:
|
||||
_LOGGER.error('Entity not found: %s', entity_id)
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
|
||||
if not config.is_entity_exposed(entity):
|
||||
_LOGGER.error('Entity not exposed: %s', entity_id)
|
||||
return web.Response(text="Entity not exposed", status=404)
|
||||
|
||||
try:
|
||||
request_json = yield from request.json()
|
||||
except ValueError:
|
||||
_LOGGER.error('Received invalid json')
|
||||
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
|
||||
|
||||
# Parse the request into requested "on" status and brightness
|
||||
parsed = parse_hue_api_put_light_body(request_json, entity)
|
||||
|
||||
if parsed is None:
|
||||
_LOGGER.error('Unable to parse data: %s', request_json)
|
||||
return web.Response(text="Bad request", status=400)
|
||||
|
||||
result, brightness = parsed
|
||||
|
||||
# Choose general HA domain
|
||||
domain = core.DOMAIN
|
||||
|
||||
# Convert the resulting "on" status into the service we need to call
|
||||
service = SERVICE_TURN_ON if result else SERVICE_TURN_OFF
|
||||
|
||||
# Construct what we need to send to the service
|
||||
data = {ATTR_ENTITY_ID: entity_id}
|
||||
|
||||
# Make sure the entity actually supports brightness
|
||||
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
|
||||
if brightness is not None:
|
||||
data[ATTR_BRIGHTNESS] = brightness
|
||||
|
||||
# If the requested entity is a script add some variables
|
||||
elif entity.domain == "script":
|
||||
data['variables'] = {
|
||||
'requested_state': STATE_ON if result else STATE_OFF
|
||||
}
|
||||
|
||||
if brightness is not None:
|
||||
data['variables']['requested_level'] = brightness
|
||||
|
||||
# If the requested entity is a media player, convert to volume
|
||||
elif entity.domain == "media_player":
|
||||
media_commands = entity.attributes.get(
|
||||
ATTR_SUPPORTED_MEDIA_COMMANDS, 0)
|
||||
if media_commands & SUPPORT_VOLUME_SET == SUPPORT_VOLUME_SET:
|
||||
if brightness is not None:
|
||||
domain = entity.domain
|
||||
service = SERVICE_VOLUME_SET
|
||||
# Convert 0-100 to 0.0-1.0
|
||||
data[ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100.0
|
||||
|
||||
if entity.domain in config.off_maps_to_on_domains:
|
||||
# Map the off command to on
|
||||
service = SERVICE_TURN_ON
|
||||
|
||||
# Caching is required because things like scripts and scenes won't
|
||||
# report as "off" to Alexa if an "off" command is received, because
|
||||
# they'll map to "on". Thus, instead of reporting its actual
|
||||
# status, we report what Alexa will want to see, which is the same
|
||||
# as the actual requested command.
|
||||
config.cached_states[entity_id] = (result, brightness)
|
||||
|
||||
# Separate call to turn on needed
|
||||
if domain != core.DOMAIN:
|
||||
hass.async_add_job(hass.services.async_call(
|
||||
core.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True))
|
||||
|
||||
hass.async_add_job(hass.services.async_call(
|
||||
domain, service, data, blocking=True))
|
||||
|
||||
json_response = \
|
||||
[create_hue_success_response(entity_id, HUE_API_STATE_ON, result)]
|
||||
|
||||
if brightness is not None:
|
||||
json_response.append(create_hue_success_response(
|
||||
entity_id, HUE_API_STATE_BRI, brightness))
|
||||
|
||||
return self.json(json_response)
|
||||
|
||||
|
||||
def parse_hue_api_put_light_body(request_json, entity):
|
||||
"""Parse the body of a request to change the state of a light."""
|
||||
if HUE_API_STATE_ON in request_json:
|
||||
if not isinstance(request_json[HUE_API_STATE_ON], bool):
|
||||
return None
|
||||
|
||||
if request_json['on']:
|
||||
# Echo requested device be turned on
|
||||
brightness = None
|
||||
report_brightness = False
|
||||
result = True
|
||||
else:
|
||||
# Echo requested device be turned off
|
||||
brightness = None
|
||||
report_brightness = False
|
||||
result = False
|
||||
|
||||
if HUE_API_STATE_BRI in request_json:
|
||||
try:
|
||||
# Clamp brightness from 0 to 255
|
||||
brightness = \
|
||||
max(0, min(int(request_json[HUE_API_STATE_BRI]), 255))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Make sure the entity actually supports brightness
|
||||
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
|
||||
report_brightness = True
|
||||
result = (brightness > 0)
|
||||
|
||||
elif entity.domain == "script" or entity.domain == "media_player":
|
||||
# Convert 0-255 to 0-100
|
||||
level = brightness / 255 * 100
|
||||
brightness = round(level)
|
||||
report_brightness = True
|
||||
result = True
|
||||
|
||||
return (result, brightness) if report_brightness else (result, None)
|
||||
|
||||
|
||||
def get_entity_state(config, entity):
|
||||
"""Retrieve and convert state and brightness values for an entity."""
|
||||
cached_state = config.cached_states.get(entity.entity_id, None)
|
||||
|
||||
if cached_state is None:
|
||||
final_state = entity.state != STATE_OFF
|
||||
final_brightness = entity.attributes.get(
|
||||
ATTR_BRIGHTNESS, 255 if final_state else 0)
|
||||
|
||||
# Make sure the entity actually supports brightness
|
||||
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
|
||||
pass
|
||||
|
||||
elif entity.domain == "media_player":
|
||||
level = entity.attributes.get(
|
||||
ATTR_MEDIA_VOLUME_LEVEL, 1.0 if final_state else 0.0)
|
||||
# Convert 0.0-1.0 to 0-255
|
||||
final_brightness = round(min(1.0, level) * 255)
|
||||
else:
|
||||
final_state, final_brightness = cached_state
|
||||
|
||||
return (final_state, final_brightness)
|
||||
|
||||
|
||||
def entity_to_json(entity, is_on=None, brightness=None):
|
||||
"""Convert an entity to its Hue bridge JSON representation."""
|
||||
name = entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name)
|
||||
|
||||
return {
|
||||
'state':
|
||||
{
|
||||
HUE_API_STATE_ON: is_on,
|
||||
HUE_API_STATE_BRI: brightness,
|
||||
'reachable': True
|
||||
},
|
||||
'type': 'Dimmable light',
|
||||
'name': name,
|
||||
'modelid': 'HASS123',
|
||||
'uniqueid': entity.entity_id,
|
||||
'swversion': '123'
|
||||
}
|
||||
|
||||
|
||||
def create_hue_success_response(entity_id, attr, value):
|
||||
"""Create a success response for an attribute set on a light."""
|
||||
success_key = '/lights/{}/state/{}'.format(entity_id, attr)
|
||||
return {'success': {success_key: value}}
|
||||
167
homeassistant/components/emulated_hue/upnp.py
Normal file
167
homeassistant/components/emulated_hue/upnp.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Provides a UPNP discovery method that mimicks Hue hubs."""
|
||||
import threading
|
||||
import socket
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DescriptionXmlView(HomeAssistantView):
|
||||
"""Handles requests for the description.xml file."""
|
||||
|
||||
url = '/description.xml'
|
||||
name = 'description:xml'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize the instance of the view."""
|
||||
self.config = config
|
||||
|
||||
@core.callback
|
||||
def get(self, request):
|
||||
"""Handle a GET request."""
|
||||
xml_template = """<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<URLBase>http://{0}:{1}/</URLBase>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
|
||||
<friendlyName>HASS Bridge ({0})</friendlyName>
|
||||
<manufacturer>Royal Philips Electronics</manufacturer>
|
||||
<manufacturerURL>http://www.philips.com</manufacturerURL>
|
||||
<modelDescription>Philips hue Personal Wireless Lighting</modelDescription>
|
||||
<modelName>Philips hue bridge 2015</modelName>
|
||||
<modelNumber>BSB002</modelNumber>
|
||||
<modelURL>http://www.meethue.com</modelURL>
|
||||
<serialNumber>1234</serialNumber>
|
||||
<UDN>uuid:2f402f80-da50-11e1-9b23-001788255acc</UDN>
|
||||
</device>
|
||||
</root>
|
||||
"""
|
||||
|
||||
resp_text = xml_template.format(
|
||||
self.config.host_ip_addr, self.config.listen_port)
|
||||
|
||||
return web.Response(text=resp_text, content_type='text/xml')
|
||||
|
||||
|
||||
class UPNPResponderThread(threading.Thread):
|
||||
"""Handle responding to UPNP/SSDP discovery requests."""
|
||||
|
||||
_interrupted = False
|
||||
|
||||
def __init__(self, host_ip_addr, listen_port):
|
||||
"""Initialize the class."""
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
self.host_ip_addr = host_ip_addr
|
||||
self.listen_port = listen_port
|
||||
|
||||
# Note that the double newline at the end of
|
||||
# this string is required per the SSDP spec
|
||||
resp_template = """HTTP/1.1 200 OK
|
||||
CACHE-CONTROL: max-age=60
|
||||
EXT:
|
||||
LOCATION: http://{0}:{1}/description.xml
|
||||
SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1
|
||||
hue-bridgeid: 1234
|
||||
ST: urn:schemas-upnp-org:device:basic:1
|
||||
USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1
|
||||
|
||||
"""
|
||||
|
||||
self.upnp_response = resp_template.format(host_ip_addr, listen_port) \
|
||||
.replace("\n", "\r\n") \
|
||||
.encode('utf-8')
|
||||
|
||||
# Set up a pipe for signaling to the receiver that it's time to
|
||||
# shutdown. Essentially, we place the SSDP socket into nonblocking
|
||||
# mode and use select() to wait for data to arrive on either the SSDP
|
||||
# socket or the pipe. If data arrives on either one, select() returns
|
||||
# and tells us which filenos have data ready to read.
|
||||
#
|
||||
# When we want to stop the responder, we write data to the pipe, which
|
||||
# causes the select() to return and indicate that said pipe has data
|
||||
# ready to be read, which indicates to us that the responder needs to
|
||||
# be shutdown.
|
||||
self._interrupted_read_pipe, self._interrupted_write_pipe = os.pipe()
|
||||
|
||||
def run(self):
|
||||
"""Run the server."""
|
||||
# Listen for UDP port 1900 packets sent to SSDP multicast address
|
||||
ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
ssdp_socket.setblocking(False)
|
||||
|
||||
# Required for receiving multicast
|
||||
ssdp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
||||
ssdp_socket.setsockopt(
|
||||
socket.SOL_IP,
|
||||
socket.IP_MULTICAST_IF,
|
||||
socket.inet_aton(self.host_ip_addr))
|
||||
|
||||
ssdp_socket.setsockopt(
|
||||
socket.SOL_IP,
|
||||
socket.IP_ADD_MEMBERSHIP,
|
||||
socket.inet_aton("239.255.255.250") +
|
||||
socket.inet_aton(self.host_ip_addr))
|
||||
|
||||
ssdp_socket.bind(("239.255.255.250", 1900))
|
||||
|
||||
while True:
|
||||
if self._interrupted:
|
||||
clean_socket_close(ssdp_socket)
|
||||
return
|
||||
|
||||
try:
|
||||
read, _, _ = select.select(
|
||||
[self._interrupted_read_pipe, ssdp_socket], [],
|
||||
[ssdp_socket])
|
||||
|
||||
if self._interrupted_read_pipe in read:
|
||||
# Implies self._interrupted is True
|
||||
clean_socket_close(ssdp_socket)
|
||||
return
|
||||
elif ssdp_socket in read:
|
||||
data, addr = ssdp_socket.recvfrom(1024)
|
||||
else:
|
||||
continue
|
||||
except socket.error as ex:
|
||||
if self._interrupted:
|
||||
clean_socket_close(ssdp_socket)
|
||||
return
|
||||
|
||||
_LOGGER.error("UPNP Responder socket exception occured: %s",
|
||||
ex.__str__)
|
||||
|
||||
if "M-SEARCH" in data.decode('utf-8'):
|
||||
# SSDP M-SEARCH method received, respond to it with our info
|
||||
resp_socket = socket.socket(
|
||||
socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
resp_socket.sendto(self.upnp_response, addr)
|
||||
resp_socket.close()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the server."""
|
||||
# Request for server
|
||||
self._interrupted = True
|
||||
os.write(self._interrupted_write_pipe, bytes([0]))
|
||||
self.join()
|
||||
|
||||
|
||||
def clean_socket_close(sock):
|
||||
"""Close a socket connection and logs its closure."""
|
||||
_LOGGER.info("UPNP responder shutting down.")
|
||||
|
||||
sock.close()
|
||||
@@ -75,8 +75,7 @@ def setup(hass, config):
|
||||
descriptions[DOMAIN][SERVICE_CHECKIN],
|
||||
schema=CHECKIN_SERVICE_SCHEMA)
|
||||
|
||||
hass.http.register_view(FoursquarePushReceiver(
|
||||
hass, config[CONF_PUSH_SECRET]))
|
||||
hass.http.register_view(FoursquarePushReceiver(config[CONF_PUSH_SECRET]))
|
||||
|
||||
return True
|
||||
|
||||
@@ -88,9 +87,8 @@ class FoursquarePushReceiver(HomeAssistantView):
|
||||
url = "/api/foursquare"
|
||||
name = "foursquare"
|
||||
|
||||
def __init__(self, hass, push_secret):
|
||||
def __init__(self, push_secret):
|
||||
"""Initialize the OAuth callback view."""
|
||||
super().__init__(hass)
|
||||
self.push_secret = push_secret
|
||||
|
||||
@asyncio.coroutine
|
||||
@@ -110,4 +108,4 @@ class FoursquarePushReceiver(HomeAssistantView):
|
||||
"push secret: %s", secret)
|
||||
return self.json_message('Incorrect secret', HTTP_BAD_REQUEST)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_PUSH, data)
|
||||
request.app['hass'].bus.async_fire(EVENT_PUSH, data)
|
||||
|
||||
@@ -8,17 +8,18 @@ import os
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START, HTTP_NOT_FOUND
|
||||
from homeassistant.const import HTTP_NOT_FOUND
|
||||
from homeassistant.components import api, group
|
||||
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
|
||||
|
||||
DOMAIN = 'frontend'
|
||||
DEPENDENCIES = ['api']
|
||||
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')
|
||||
PANELS = {}
|
||||
MANIFEST_JSON = {
|
||||
"background_color": "#FFFFFF",
|
||||
"description": "Open-source home automation platform running on Python 3.",
|
||||
@@ -32,6 +33,16 @@ MANIFEST_JSON = {
|
||||
"theme_color": "#03A9F4"
|
||||
}
|
||||
|
||||
for size in (192, 384, 512, 1024):
|
||||
MANIFEST_JSON['icons'].append({
|
||||
"src": "/static/icons/favicon-{}x{}.png".format(size, size),
|
||||
"sizes": "{}x{}".format(size, size),
|
||||
"type": "image/png"
|
||||
})
|
||||
|
||||
DATA_PANELS = 'frontend_panels'
|
||||
DATA_INDEX_VIEW = 'frontend_index_view'
|
||||
|
||||
# To keep track we don't register a component twice (gives a warning)
|
||||
_REGISTERED_COMPONENTS = set()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -68,10 +79,14 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None,
|
||||
|
||||
Warning: this API will probably change. Use at own risk.
|
||||
"""
|
||||
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:
|
||||
if url_path in panels:
|
||||
_LOGGER.warning('Overwriting component %s', url_path)
|
||||
if not os.path.isfile(path):
|
||||
_LOGGER.error('Panel %s component does not exist: %s',
|
||||
@@ -106,7 +121,15 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None,
|
||||
fprinted_url = URL_PANEL_COMPONENT_FP.format(component_name, md5)
|
||||
data['url'] = fprinted_url
|
||||
|
||||
PANELS[url_path] = data
|
||||
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)
|
||||
|
||||
|
||||
def add_manifest_json_key(key, val):
|
||||
@@ -134,29 +157,24 @@ def setup(hass, config):
|
||||
if os.path.isdir(local):
|
||||
hass.http.register_static_path("/local", local)
|
||||
|
||||
index_view = hass.data[DATA_INDEX_VIEW] = IndexView()
|
||||
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)
|
||||
else:
|
||||
hass.data[DATA_PANELS] = {}
|
||||
|
||||
register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location')
|
||||
|
||||
for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state',
|
||||
'dev-template'):
|
||||
register_built_in_panel(hass, panel)
|
||||
|
||||
def register_frontend_index(event):
|
||||
"""Register the frontend index urls.
|
||||
|
||||
Done when Home Assistant is started so that all panels are known.
|
||||
"""
|
||||
hass.http.register_view(IndexView(
|
||||
hass, ['/{}'.format(name) for name in PANELS]))
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, register_frontend_index)
|
||||
|
||||
for size in (192, 384, 512, 1024):
|
||||
MANIFEST_JSON['icons'].append({
|
||||
"src": "/static/icons/favicon-{}x{}.png".format(size, size),
|
||||
"sizes": "{}x{}".format(size, size),
|
||||
"type": "image/png"
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -169,12 +187,14 @@ class BootstrapView(HomeAssistantView):
|
||||
@callback
|
||||
def get(self, request):
|
||||
"""Return all data needed to bootstrap Home Assistant."""
|
||||
hass = request.app['hass']
|
||||
|
||||
return self.json({
|
||||
'config': self.hass.config.as_dict(),
|
||||
'states': self.hass.states.async_all(),
|
||||
'events': api.async_events_json(self.hass),
|
||||
'services': api.async_services_json(self.hass),
|
||||
'panels': PANELS,
|
||||
'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],
|
||||
})
|
||||
|
||||
|
||||
@@ -186,13 +206,10 @@ class IndexView(HomeAssistantView):
|
||||
requires_auth = False
|
||||
extra_urls = ['/states', '/states/{entity_id}']
|
||||
|
||||
def __init__(self, hass, extra_urls):
|
||||
def __init__(self):
|
||||
"""Initialize the frontend view."""
|
||||
super().__init__(hass)
|
||||
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
self.extra_urls = self.extra_urls + extra_urls
|
||||
self.templates = Environment(
|
||||
loader=FileSystemLoader(
|
||||
os.path.join(os.path.dirname(__file__), 'templates/')
|
||||
@@ -202,14 +219,16 @@ class IndexView(HomeAssistantView):
|
||||
@asyncio.coroutine
|
||||
def get(self, request, entity_id=None):
|
||||
"""Serve the index view."""
|
||||
hass = request.app['hass']
|
||||
|
||||
if entity_id is not None:
|
||||
state = self.hass.states.get(entity_id)
|
||||
state = hass.states.get(entity_id)
|
||||
|
||||
if (not state or state.domain != 'group' or
|
||||
not state.attributes.get(group.ATTR_VIEW)):
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
|
||||
if self.hass.http.development:
|
||||
if request.app[KEY_DEVELOPMENT]:
|
||||
core_url = '/static/home-assistant-polymer/build/core.js'
|
||||
ui_url = '/static/home-assistant-polymer/src/home-assistant.html'
|
||||
else:
|
||||
@@ -223,19 +242,21 @@ class IndexView(HomeAssistantView):
|
||||
else:
|
||||
panel = request.path.split('/')[1]
|
||||
|
||||
panel_url = PANELS[panel]['url'] if panel != 'states' else ''
|
||||
if panel == 'states':
|
||||
panel_url = ''
|
||||
else:
|
||||
panel_url = hass.data[DATA_PANELS][panel]['url']
|
||||
|
||||
no_auth = 'true'
|
||||
if self.hass.config.api.api_password:
|
||||
if hass.config.api.api_password:
|
||||
# require password if set
|
||||
no_auth = 'false'
|
||||
if self.hass.http.is_trusted_ip(
|
||||
self.hass.http.get_real_ip(request)):
|
||||
if is_trusted_ip(request):
|
||||
# bypass for trusted networks
|
||||
no_auth = 'true'
|
||||
|
||||
icons_url = '/static/mdi-{}.html'.format(FINGERPRINTS['mdi.html'])
|
||||
template = yield from self.hass.loop.run_in_executor(
|
||||
template = yield from hass.loop.run_in_executor(
|
||||
None, self.templates.get_template, 'index.html')
|
||||
|
||||
# pylint is wrong
|
||||
@@ -244,7 +265,7 @@ class IndexView(HomeAssistantView):
|
||||
resp = template.render(
|
||||
core_url=core_url, ui_url=ui_url, no_auth=no_auth,
|
||||
icons_url=icons_url, icons=FINGERPRINTS['mdi.html'],
|
||||
panel_url=panel_url, panels=PANELS)
|
||||
panel_url=panel_url, panels=hass.data[DATA_PANELS])
|
||||
|
||||
return web.Response(text=resp, content_type='text/html')
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""DO NOT MODIFY. Auto-generated by script/fingerprint_frontend."""
|
||||
|
||||
FINGERPRINTS = {
|
||||
"core.js": "5ed5e063d66eb252b5b288738c9c2d16",
|
||||
"frontend.html": "78be2dfedc4e95326cbcd9401fb17b4d",
|
||||
"core.js": "ad1ebcd0614c98a390d982087a7ca75c",
|
||||
"frontend.html": "826ee6a4b39c939e31aa468b1ef618f9",
|
||||
"mdi.html": "46a76f877ac9848899b8ed382427c16f",
|
||||
"micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a",
|
||||
"panels/ha-panel-dev-event.html": "550bf85345c454274a40d15b2795a002",
|
||||
"panels/ha-panel-dev-info.html": "ec613406ce7e20d93754233d55625c8a",
|
||||
"panels/ha-panel-dev-service.html": "4a051878b92b002b8b018774ba207769",
|
||||
"panels/ha-panel-dev-event.html": "c2d5ec676be98d4474d19f94d0262c1e",
|
||||
"panels/ha-panel-dev-info.html": "a9c07bf281fe9791fb15827ec1286825",
|
||||
"panels/ha-panel-dev-service.html": "ac74f7ce66fd7136d25c914ea12f4351",
|
||||
"panels/ha-panel-dev-state.html": "65e5f791cc467561719bf591f1386054",
|
||||
"panels/ha-panel-dev-template.html": "7d744ab7f7c08b6d6ad42069989de400",
|
||||
"panels/ha-panel-history.html": "efe1bcdd7733b09e55f4f965d171c295",
|
||||
"panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab",
|
||||
"panels/ha-panel-logbook.html": "66108d82763359a218c9695f0553de40",
|
||||
"panels/ha-panel-map.html": "49ab2d6f180f8bdea7cffaa66b8a5d3e"
|
||||
"panels/ha-panel-logbook.html": "4bc5c8370a85a4215413fbae8f85addb",
|
||||
"panels/ha-panel-map.html": "3b0ca63286cbe80f27bd36dbc2434e89",
|
||||
"websocket_test.html": "575de64b431fe11c3785bf96d7813450"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1,2 +1,2 @@
|
||||
<html><head><meta charset="UTF-8"></head><body><dom-module id="ha-panel-dev-info"><template><style include="iron-positioning ha-style">:host{background-color:#fff;-ms-user-select:initial;-webkit-user-select:initial;-moz-user-select:initial}.content{padding:24px}.about{text-align:center;line-height:2em}.version{@apply(--paper-font-headline)}.develop{@apply(--paper-font-subhead)}.about a{color:var(--dark-primary-color)}.error-log-intro{margin-top:16px;border-top:1px solid var(--light-primary-color);padding-top:16px}paper-icon-button{float:right}.error-log{@apply(--paper-font-code1)
|
||||
clear: both;white-space:pre-wrap}</style><app-header-layout has-scrolling-region=""><app-header fixed=""><app-toolbar><ha-menu-button narrow="[[narrow]]" show-menu="[[showMenu]]"></ha-menu-button><div main-title="">About</div></app-toolbar></app-header><div class="content fit"><div class="about"><p class="version"><a href="https://home-assistant.io"><img src="/static/icons/favicon-192x192.png" height="192"></a><br>Home Assistant<br>[[hassVersion]]</p><p>Path to configuration.yaml: [[hassConfigDir]]</p><p class="develop"><a href="https://home-assistant.io/developers/credits/" target="_blank">Developed by a bunch of awesome people.</a></p><p>Published under the MIT license<br>Source: <a href="https://github.com/home-assistant/home-assistant" target="_blank">server</a> — <a href="https://github.com/home-assistant/home-assistant-polymer" target="_blank">frontend-ui</a> — <a href="https://github.com/home-assistant/home-assistant-js" target="_blank">frontend-core</a></p><p>Built using <a href="https://www.python.org">Python 3</a>, <a href="https://www.polymer-project.org" target="_blank">Polymer [[polymerVersion]]</a>, <a href="https://optimizely.github.io/nuclear-js/" target="_blank">NuclearJS [[nuclearVersion]]</a><br>Icons by <a href="https://www.google.com/design/icons/" target="_blank">Google</a> and <a href="https://MaterialDesignIcons.com" target="_blank">MaterialDesignIcons.com</a>.</p></div><p class="error-log-intro">The following errors have been logged this session:<paper-icon-button icon="mdi:refresh" on-tap="refreshErrorLog"></paper-icon-button></p><div class="error-log">[[errorLog]]</div></div></app-header-layout></template></dom-module><script>Polymer({is:"ha-panel-dev-info",behaviors:[window.hassBehavior],properties:{hass:{type:Object},narrow:{type:Boolean,value:!1},showMenu:{type:Boolean,value:!1},hassVersion:{type:String,bindNuclear:function(r){return r.configGetters.serverVersion}},hassConfigDir:{type:String,bindNuclear:function(r){return r.configGetters.configDir}},polymerVersion:{type:String,value:Polymer.version},nuclearVersion:{type:String,value:"1.3.0"},errorLog:{type:String,value:""}},attached:function(){this.refreshErrorLog()},refreshErrorLog:function(r){r&&r.preventDefault(),this.errorLog="Loading error log…",this.hass.errorLogActions.fetchErrorLog().then(function(r){this.errorLog=r||"No errors have been reported."}.bind(this))}})</script></body></html>
|
||||
clear: both;white-space:pre-wrap}</style><app-header-layout has-scrolling-region=""><app-header fixed=""><app-toolbar><ha-menu-button narrow="[[narrow]]" show-menu="[[showMenu]]"></ha-menu-button><div main-title="">About</div></app-toolbar></app-header><div class="content fit"><div class="about"><p class="version"><a href="https://home-assistant.io"><img src="/static/icons/favicon-192x192.png" height="192"></a><br>Home Assistant<br>[[hassVersion]]</p><p>Path to configuration.yaml: [[hassConfigDir]]</p><p class="develop"><a href="https://home-assistant.io/developers/credits/" target="_blank">Developed by a bunch of awesome people.</a></p><p>Published under the MIT license<br>Source: <a href="https://github.com/home-assistant/home-assistant" target="_blank">server</a> — <a href="https://github.com/home-assistant/home-assistant-polymer" target="_blank">frontend-ui</a> — <a href="https://github.com/home-assistant/home-assistant-js" target="_blank">frontend-core</a></p><p>Built using <a href="https://www.python.org">Python 3</a>, <a href="https://www.polymer-project.org" target="_blank">Polymer [[polymerVersion]]</a>, <a href="https://optimizely.github.io/nuclear-js/" target="_blank">NuclearJS [[nuclearVersion]]</a><br>Icons by <a href="https://www.google.com/design/icons/" target="_blank">Google</a> and <a href="https://MaterialDesignIcons.com" target="_blank">MaterialDesignIcons.com</a>.</p></div><p class="error-log-intro">The following errors have been logged this session:<paper-icon-button icon="mdi:refresh" on-tap="refreshErrorLog"></paper-icon-button></p><div class="error-log">[[errorLog]]</div></div></app-header-layout></template></dom-module><script>Polymer({is:"ha-panel-dev-info",behaviors:[window.hassBehavior],properties:{hass:{type:Object},narrow:{type:Boolean,value:!1},showMenu:{type:Boolean,value:!1},hassVersion:{type:String,bindNuclear:function(r){return r.configGetters.serverVersion}},hassConfigDir:{type:String,bindNuclear:function(r){return r.configGetters.configDir}},polymerVersion:{type:String,value:Polymer.version},nuclearVersion:{type:String,value:"1.4.0"},errorLog:{type:String,value:""}},attached:function(){this.refreshErrorLog()},refreshErrorLog:function(r){r&&r.preventDefault(),this.errorLog="Loading error log…",this.hass.errorLogActions.fetchErrorLog().then(function(r){this.errorLog=r||"No errors have been reported."}.bind(this))}})</script></body></html>
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
125
homeassistant/components/frontend/www_static/websocket_test.html
Normal file
125
homeassistant/components/frontend/www_static/websocket_test.html
Normal file
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>WebSocket debug</title>
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.controls textarea {
|
||||
height: 160px;
|
||||
min-width: 400px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class='controls'>
|
||||
<textarea id="messageinput">
|
||||
{
|
||||
"id": 1, "type": "subscribe_events", "event_type": "state_changed"
|
||||
}
|
||||
</textarea>
|
||||
<pre>
|
||||
Examples:
|
||||
{
|
||||
"id": 2, "type": "subscribe_events", "event_type": "state_changed"
|
||||
}
|
||||
|
||||
{
|
||||
"id": 3, "type": "call_service", "domain": "light", "service": "turn_off"
|
||||
}
|
||||
|
||||
{
|
||||
"id": 4, "type": "unsubscribe_events", "subscription": 2
|
||||
}
|
||||
|
||||
{
|
||||
"id": 5, "type": "get_states"
|
||||
}
|
||||
|
||||
{
|
||||
"id": 6, "type": "get_config"
|
||||
}
|
||||
|
||||
{
|
||||
"id": 7, "type": "get_services"
|
||||
}
|
||||
|
||||
{
|
||||
"id": 8, "type": "get_panels"
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" onclick="openSocket();" >Open</button>
|
||||
<button type="button" onclick="send();" >Send</button>
|
||||
<button type="button" onclick="closeSocket();" >Close</button>
|
||||
</div>
|
||||
<!-- Server responses get written here -->
|
||||
<pre id="messages"></pre>
|
||||
|
||||
<!-- Script to utilise the WebSocket -->
|
||||
<script type="text/javascript">
|
||||
var webSocket;
|
||||
var messages = document.getElementById("messages");
|
||||
|
||||
function openSocket(){
|
||||
var isOpen = false;
|
||||
// Ensures only one connection is open at a time
|
||||
if(webSocket !== undefined && webSocket.readyState !== WebSocket.CLOSED){
|
||||
writeResponse("WebSocket is already opened.");
|
||||
return;
|
||||
}
|
||||
// Create a new instance of the websocket
|
||||
webSocket = new WebSocket("ws://localhost:8123/api/websocket");
|
||||
|
||||
/**
|
||||
* Binds functions to the listeners for the websocket.
|
||||
*/
|
||||
webSocket.onopen = function(event){
|
||||
if (!isOpen) {
|
||||
isOpen = true;
|
||||
writeResponse('Connection opened');
|
||||
}
|
||||
// For reasons I can't determine, onopen gets called twice
|
||||
// and the first time event.data is undefined.
|
||||
// Leave a comment if you know the answer.
|
||||
if(event.data === undefined)
|
||||
return;
|
||||
|
||||
writeResponse(event.data);
|
||||
};
|
||||
|
||||
webSocket.onmessage = function(event){
|
||||
writeResponse(event.data);
|
||||
};
|
||||
|
||||
webSocket.onclose = function(event){
|
||||
writeResponse("Connection closed");
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the value of the text input to the server
|
||||
*/
|
||||
function send(){
|
||||
var text = document.getElementById("messageinput").value;
|
||||
webSocket.send(text);
|
||||
}
|
||||
|
||||
function closeSocket(){
|
||||
webSocket.close();
|
||||
}
|
||||
|
||||
function writeResponse(text){
|
||||
messages.innerHTML += "\n" + text;
|
||||
}
|
||||
|
||||
openSocket();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -29,11 +29,13 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
CONF_ENTITIES = 'entities'
|
||||
CONF_VIEW = 'view'
|
||||
CONF_CONTROL = 'control'
|
||||
|
||||
ATTR_AUTO = 'auto'
|
||||
ATTR_ORDER = 'order'
|
||||
ATTR_VIEW = 'view'
|
||||
ATTR_VISIBLE = 'visible'
|
||||
ATTR_CONTROL = 'control'
|
||||
|
||||
SERVICE_SET_VISIBILITY = 'set_visibility'
|
||||
SET_VISIBILITY_SERVICE_SCHEMA = vol.Schema({
|
||||
@@ -61,6 +63,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
CONF_VIEW: cv.boolean,
|
||||
CONF_NAME: cv.string,
|
||||
CONF_ICON: cv.icon,
|
||||
CONF_CONTROL: cv.string,
|
||||
}, cv.match_all))
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
@@ -206,11 +209,13 @@ def _async_process_config(hass, config, component):
|
||||
entity_ids = conf.get(CONF_ENTITIES) or []
|
||||
icon = conf.get(CONF_ICON)
|
||||
view = conf.get(CONF_VIEW)
|
||||
control = conf.get(CONF_CONTROL)
|
||||
|
||||
# Don't create tasks and await them all. The order is important as
|
||||
# groups get a number based on creation order.
|
||||
group = yield from Group.async_create_group(
|
||||
hass, name, entity_ids, icon=icon, view=view, object_id=object_id)
|
||||
hass, name, entity_ids, icon=icon, view=view,
|
||||
control=control, object_id=object_id)
|
||||
groups.append(group)
|
||||
|
||||
if groups:
|
||||
@@ -221,7 +226,7 @@ class Group(Entity):
|
||||
"""Track a group of entity ids."""
|
||||
|
||||
def __init__(self, hass, name, order=None, user_defined=True, icon=None,
|
||||
view=False):
|
||||
view=False, control=None):
|
||||
"""Initialize a group.
|
||||
|
||||
This Object has factory function for creation.
|
||||
@@ -239,20 +244,22 @@ class Group(Entity):
|
||||
self._assumed_state = False
|
||||
self._async_unsub_state_changed = None
|
||||
self._visible = True
|
||||
self._control = control
|
||||
|
||||
@staticmethod
|
||||
def create_group(hass, name, entity_ids=None, user_defined=True,
|
||||
icon=None, view=False, object_id=None):
|
||||
icon=None, view=False, control=None, object_id=None):
|
||||
"""Initialize a group."""
|
||||
return run_coroutine_threadsafe(
|
||||
Group.async_create_group(hass, name, entity_ids, user_defined,
|
||||
icon, view, object_id),
|
||||
icon, view, control, object_id),
|
||||
hass.loop).result()
|
||||
|
||||
@staticmethod
|
||||
@asyncio.coroutine
|
||||
def async_create_group(hass, name, entity_ids=None, user_defined=True,
|
||||
icon=None, view=False, object_id=None):
|
||||
icon=None, view=False, control=None,
|
||||
object_id=None):
|
||||
"""Initialize a group.
|
||||
|
||||
This method must be run in the event loop.
|
||||
@@ -260,7 +267,8 @@ class Group(Entity):
|
||||
group = Group(
|
||||
hass, name,
|
||||
order=len(hass.states.async_entity_ids(DOMAIN)),
|
||||
user_defined=user_defined, icon=icon, view=view)
|
||||
user_defined=user_defined, icon=icon, view=view,
|
||||
control=control)
|
||||
|
||||
group.entity_id = async_generate_entity_id(
|
||||
ENTITY_ID_FORMAT, object_id or name, hass=hass)
|
||||
@@ -319,6 +327,8 @@ class Group(Entity):
|
||||
data[ATTR_AUTO] = True
|
||||
if self._view:
|
||||
data[ATTR_VIEW] = True
|
||||
if self._control:
|
||||
data[ATTR_CONTROL] = self._control
|
||||
return data
|
||||
|
||||
@property
|
||||
|
||||
@@ -184,8 +184,8 @@ def setup(hass, config):
|
||||
filters.included_entities = include[CONF_ENTITIES]
|
||||
filters.included_domains = include[CONF_DOMAINS]
|
||||
|
||||
hass.http.register_view(Last5StatesView(hass))
|
||||
hass.http.register_view(HistoryPeriodView(hass, filters))
|
||||
hass.http.register_view(Last5StatesView)
|
||||
hass.http.register_view(HistoryPeriodView(filters))
|
||||
register_built_in_panel(hass, 'history', 'History', 'mdi:poll-box')
|
||||
|
||||
return True
|
||||
@@ -197,14 +197,10 @@ class Last5StatesView(HomeAssistantView):
|
||||
url = '/api/history/entity/{entity_id}/recent_states'
|
||||
name = 'api:history:entity-recent-states'
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initilalize the history last 5 states view."""
|
||||
super().__init__(hass)
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request, entity_id):
|
||||
"""Retrieve last 5 states of entity."""
|
||||
result = yield from self.hass.loop.run_in_executor(
|
||||
result = yield from request.app['hass'].loop.run_in_executor(
|
||||
None, last_5_states, entity_id)
|
||||
return self.json(result)
|
||||
|
||||
@@ -216,9 +212,8 @@ class HistoryPeriodView(HomeAssistantView):
|
||||
name = 'api:history:view-period'
|
||||
extra_urls = ['/api/history/period/{datetime}']
|
||||
|
||||
def __init__(self, hass, filters):
|
||||
def __init__(self, filters):
|
||||
"""Initilalize the history period view."""
|
||||
super().__init__(hass)
|
||||
self.filters = filters
|
||||
|
||||
@asyncio.coroutine
|
||||
@@ -240,7 +235,7 @@ class HistoryPeriodView(HomeAssistantView):
|
||||
end_time = start_time + one_day
|
||||
entity_id = request.GET.get('filter_entity_id')
|
||||
|
||||
result = yield from self.hass.loop.run_in_executor(
|
||||
result = yield from request.app['hass'].loop.run_in_executor(
|
||||
None, get_significant_states, start_time, end_time, entity_id,
|
||||
self.filters)
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ from functools import partial
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN,
|
||||
CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM,
|
||||
ATTR_ENTITY_ID)
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID)
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers import discovery
|
||||
@@ -23,13 +23,10 @@ from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
DOMAIN = 'homematic'
|
||||
REQUIREMENTS = ["pyhomematic==0.1.16"]
|
||||
|
||||
HOMEMATIC = None
|
||||
HOMEMATIC_LINK_DELAY = 0.5
|
||||
REQUIREMENTS = ["pyhomematic==0.1.18"]
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATE_HUB = timedelta(seconds=300)
|
||||
MIN_TIME_BETWEEN_UPDATE_VAR = timedelta(seconds=60)
|
||||
MIN_TIME_BETWEEN_UPDATE_VAR = timedelta(seconds=30)
|
||||
|
||||
DISCOVER_SWITCHES = 'homematic.switch'
|
||||
DISCOVER_LIGHTS = 'homematic.light'
|
||||
@@ -44,12 +41,15 @@ ATTR_CHANNEL = 'channel'
|
||||
ATTR_NAME = 'name'
|
||||
ATTR_ADDRESS = 'address'
|
||||
ATTR_VALUE = 'value'
|
||||
ATTR_PROXY = 'proxy'
|
||||
|
||||
EVENT_KEYPRESS = 'homematic.keypress'
|
||||
EVENT_IMPULSE = 'homematic.impulse'
|
||||
|
||||
SERVICE_VIRTUALKEY = 'virtualkey'
|
||||
SERVICE_SET_VALUE = 'set_value'
|
||||
SERVICE_RECONNECT = 'reconnect'
|
||||
SERVICE_SET_VAR_VALUE = 'set_var_value'
|
||||
SERVICE_SET_DEV_VALUE = 'set_dev_value'
|
||||
|
||||
HM_DEVICE_TYPES = {
|
||||
DISCOVER_SWITCHES: [
|
||||
@@ -109,44 +109,60 @@ CONF_RESOLVENAMES_OPTIONS = [
|
||||
False
|
||||
]
|
||||
|
||||
DATA_HOMEMATIC = 'homematic'
|
||||
DATA_DELAY = 'homematic_delay'
|
||||
DATA_DEVINIT = 'homematic_devinit'
|
||||
DATA_STORE = 'homematic_store'
|
||||
|
||||
CONF_LOCAL_IP = 'local_ip'
|
||||
CONF_LOCAL_PORT = 'local_port'
|
||||
CONF_REMOTE_IP = 'remote_ip'
|
||||
CONF_REMOTE_PORT = 'remote_port'
|
||||
CONF_IP = 'ip'
|
||||
CONF_PORT = 'port'
|
||||
CONF_RESOLVENAMES = 'resolvenames'
|
||||
CONF_DELAY = 'delay'
|
||||
CONF_VARIABLES = 'variables'
|
||||
CONF_DEVICES = 'devices'
|
||||
CONF_DELAY = 'delay'
|
||||
CONF_PRIMARY = 'primary'
|
||||
|
||||
DEFAULT_LOCAL_IP = "0.0.0.0"
|
||||
DEFAULT_LOCAL_PORT = 0
|
||||
DEFAULT_RESOLVENAMES = False
|
||||
DEFAULT_REMOTE_PORT = 2001
|
||||
DEFAULT_PORT = 2001
|
||||
DEFAULT_USERNAME = "Admin"
|
||||
DEFAULT_PASSWORD = ""
|
||||
DEFAULT_VARIABLES = False
|
||||
DEFAULT_DEVICES = True
|
||||
DEFAULT_DELAY = 0.5
|
||||
DEFAULT_PRIMARY = False
|
||||
|
||||
|
||||
DEVICE_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_PLATFORM): "homematic",
|
||||
vol.Required(ATTR_NAME): cv.string,
|
||||
vol.Required(ATTR_ADDRESS): cv.string,
|
||||
vol.Required(ATTR_PROXY): cv.string,
|
||||
vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int),
|
||||
vol.Optional(ATTR_PARAM): cv.string,
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_REMOTE_IP): cv.string,
|
||||
vol.Required(CONF_HOSTS): {cv.match_all: {
|
||||
vol.Required(CONF_IP): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT):
|
||||
cv.port,
|
||||
vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES):
|
||||
cv.boolean,
|
||||
vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES):
|
||||
vol.In(CONF_RESOLVENAMES_OPTIONS),
|
||||
vol.Optional(CONF_DEVICES, default=DEFAULT_DEVICES): cv.boolean,
|
||||
vol.Optional(CONF_PRIMARY, default=DEFAULT_PRIMARY): cv.boolean,
|
||||
}},
|
||||
vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string,
|
||||
vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port,
|
||||
vol.Optional(CONF_REMOTE_PORT, default=DEFAULT_REMOTE_PORT): cv.port,
|
||||
vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES):
|
||||
vol.In(CONF_RESOLVENAMES_OPTIONS),
|
||||
vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): vol.Coerce(float),
|
||||
vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES): cv.boolean,
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
@@ -154,105 +170,155 @@ SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({
|
||||
vol.Required(ATTR_ADDRESS): cv.string,
|
||||
vol.Required(ATTR_CHANNEL): vol.Coerce(int),
|
||||
vol.Required(ATTR_PARAM): cv.string,
|
||||
vol.Optional(ATTR_PROXY): cv.string,
|
||||
})
|
||||
|
||||
SCHEMA_SERVICE_SET_VALUE = vol.Schema({
|
||||
SCHEMA_SERVICE_SET_VAR_VALUE = vol.Schema({
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Required(ATTR_VALUE): cv.match_all,
|
||||
})
|
||||
|
||||
SCHEMA_SERVICE_SET_DEV_VALUE = vol.Schema({
|
||||
vol.Required(ATTR_ADDRESS): cv.string,
|
||||
vol.Required(ATTR_CHANNEL): vol.Coerce(int),
|
||||
vol.Required(ATTR_PARAM): cv.string,
|
||||
vol.Required(ATTR_VALUE): cv.match_all,
|
||||
vol.Optional(ATTR_PROXY): cv.string,
|
||||
})
|
||||
|
||||
def virtualkey(hass, address, channel, param):
|
||||
SCHEMA_SERVICE_RECONNECT = vol.Schema({})
|
||||
|
||||
|
||||
def virtualkey(hass, address, channel, param, proxy=None):
|
||||
"""Send virtual keypress to homematic controlller."""
|
||||
data = {
|
||||
ATTR_ADDRESS: address,
|
||||
ATTR_CHANNEL: channel,
|
||||
ATTR_PARAM: param,
|
||||
ATTR_PROXY: proxy,
|
||||
}
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data)
|
||||
|
||||
|
||||
def set_value(hass, entity_id, value):
|
||||
def set_var_value(hass, entity_id, value):
|
||||
"""Change value of homematic system variable."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_VALUE: value,
|
||||
}
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_SET_VALUE, data)
|
||||
hass.services.call(DOMAIN, SERVICE_SET_VAR_VALUE, data)
|
||||
|
||||
|
||||
def set_dev_value(hass, address, channel, param, value, proxy=None):
|
||||
"""Send virtual keypress to homematic controlller."""
|
||||
data = {
|
||||
ATTR_ADDRESS: address,
|
||||
ATTR_CHANNEL: channel,
|
||||
ATTR_PARAM: param,
|
||||
ATTR_VALUE: value,
|
||||
ATTR_PROXY: proxy,
|
||||
}
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_SET_DEV_VALUE, data)
|
||||
|
||||
|
||||
def reconnect(hass):
|
||||
"""Reconnect to CCU/Homegear."""
|
||||
hass.services.call(DOMAIN, SERVICE_RECONNECT, {})
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup(hass, config):
|
||||
"""Setup the Homematic component."""
|
||||
global HOMEMATIC, HOMEMATIC_LINK_DELAY
|
||||
from pyhomematic import HMConnection
|
||||
|
||||
component = EntityComponent(_LOGGER, DOMAIN, hass)
|
||||
|
||||
local_ip = config[DOMAIN].get(CONF_LOCAL_IP)
|
||||
local_port = config[DOMAIN].get(CONF_LOCAL_PORT)
|
||||
remote_ip = config[DOMAIN].get(CONF_REMOTE_IP)
|
||||
remote_port = config[DOMAIN].get(CONF_REMOTE_PORT)
|
||||
resolvenames = config[DOMAIN].get(CONF_RESOLVENAMES)
|
||||
username = config[DOMAIN].get(CONF_USERNAME)
|
||||
password = config[DOMAIN].get(CONF_PASSWORD)
|
||||
HOMEMATIC_LINK_DELAY = config[DOMAIN].get(CONF_DELAY)
|
||||
use_variables = config[DOMAIN].get(CONF_VARIABLES)
|
||||
hass.data[DATA_DELAY] = config[DOMAIN].get(CONF_DELAY)
|
||||
hass.data[DATA_DEVINIT] = {}
|
||||
hass.data[DATA_STORE] = []
|
||||
|
||||
if remote_ip is None or local_ip is None:
|
||||
_LOGGER.error("Missing remote CCU/Homegear or local address")
|
||||
return False
|
||||
# create hosts list for pyhomematic
|
||||
remotes = {}
|
||||
hosts = {}
|
||||
for rname, rconfig in config[DOMAIN][CONF_HOSTS].items():
|
||||
server = rconfig.get(CONF_IP)
|
||||
|
||||
remotes[rname] = {}
|
||||
remotes[rname][CONF_IP] = server
|
||||
remotes[rname][CONF_PORT] = rconfig.get(CONF_PORT)
|
||||
remotes[rname][CONF_RESOLVENAMES] = rconfig.get(CONF_RESOLVENAMES)
|
||||
remotes[rname][CONF_USERNAME] = rconfig.get(CONF_USERNAME)
|
||||
remotes[rname][CONF_PASSWORD] = rconfig.get(CONF_PASSWORD)
|
||||
|
||||
if server not in hosts or rconfig.get(CONF_PRIMARY):
|
||||
hosts[server] = {
|
||||
CONF_VARIABLES: rconfig.get(CONF_VARIABLES),
|
||||
CONF_NAME: rname,
|
||||
}
|
||||
hass.data[DATA_DEVINIT][rname] = rconfig.get(CONF_DEVICES)
|
||||
|
||||
# Create server thread
|
||||
bound_system_callback = partial(_system_callback_handler, hass, config)
|
||||
HOMEMATIC = HMConnection(local=local_ip,
|
||||
localport=local_port,
|
||||
remote=remote_ip,
|
||||
remoteport=remote_port,
|
||||
systemcallback=bound_system_callback,
|
||||
resolvenames=resolvenames,
|
||||
rpcusername=username,
|
||||
rpcpassword=password,
|
||||
interface_id="homeassistant")
|
||||
hass.data[DATA_HOMEMATIC] = HMConnection(
|
||||
local=config[DOMAIN].get(CONF_LOCAL_IP),
|
||||
localport=config[DOMAIN].get(CONF_LOCAL_PORT),
|
||||
remotes=remotes,
|
||||
systemcallback=bound_system_callback,
|
||||
interface_id="homeassistant"
|
||||
)
|
||||
|
||||
# Start server thread, connect to peer, initialize to receive events
|
||||
HOMEMATIC.start()
|
||||
hass.data[DATA_HOMEMATIC].start()
|
||||
|
||||
# Stops server when Homeassistant is shutting down
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, HOMEMATIC.stop)
|
||||
hass.bus.listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop)
|
||||
hass.config.components.append(DOMAIN)
|
||||
|
||||
# init homematic hubs
|
||||
hub_entities = []
|
||||
for _, hub_data in hosts.items():
|
||||
hub_entities.append(HMHub(hass, component, hub_data[CONF_NAME],
|
||||
hub_data[CONF_VARIABLES]))
|
||||
component.add_entities(hub_entities)
|
||||
|
||||
# regeister homematic services
|
||||
descriptions = load_yaml_config_file(
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_VIRTUALKEY,
|
||||
_hm_service_virtualkey,
|
||||
descriptions[DOMAIN][SERVICE_VIRTUALKEY],
|
||||
schema=SCHEMA_SERVICE_VIRTUALKEY)
|
||||
def _hm_service_virtualkey(service):
|
||||
"""Service handle virtualkey services."""
|
||||
address = service.data.get(ATTR_ADDRESS)
|
||||
channel = service.data.get(ATTR_CHANNEL)
|
||||
param = service.data.get(ATTR_PARAM)
|
||||
|
||||
entities = []
|
||||
# device not found
|
||||
hmdevice = _device_from_servicecall(hass, service)
|
||||
if hmdevice is None:
|
||||
_LOGGER.error("%s not found for service virtualkey!", address)
|
||||
return
|
||||
|
||||
##
|
||||
# init HM variable
|
||||
variables = HOMEMATIC.getAllSystemVariables() if use_variables else {}
|
||||
hm_var_store = {}
|
||||
if variables is not None:
|
||||
for key, value in variables.items():
|
||||
varia = HMVariable(key, value)
|
||||
hm_var_store.update({key: varia})
|
||||
entities.append(varia)
|
||||
# if param exists for this device
|
||||
if param not in hmdevice.ACTIONNODE:
|
||||
_LOGGER.error("%s not datapoint in hm device %s", param, address)
|
||||
return
|
||||
|
||||
# add homematic entites
|
||||
entities.append(HMHub(hm_var_store, use_variables))
|
||||
component.add_entities(entities)
|
||||
# channel exists?
|
||||
if channel not in hmdevice.ACTIONNODE[param]:
|
||||
_LOGGER.error("%i is not a channel in hm device %s",
|
||||
channel, address)
|
||||
return
|
||||
|
||||
##
|
||||
# register set_value service if exists variables
|
||||
if not variables:
|
||||
return True
|
||||
# call key
|
||||
hmdevice.actionNodeData(param, True, channel)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey,
|
||||
descriptions[DOMAIN][SERVICE_VIRTUALKEY],
|
||||
schema=SCHEMA_SERVICE_VIRTUALKEY)
|
||||
|
||||
def _service_handle_value(service):
|
||||
"""Set value on homematic variable object."""
|
||||
@@ -261,12 +327,43 @@ def setup(hass, config):
|
||||
value = service.data[ATTR_VALUE]
|
||||
|
||||
for hm_variable in variable_list:
|
||||
hm_variable.hm_set(value)
|
||||
if isinstance(hm_variable, HMVariable):
|
||||
hm_variable.hm_set(value)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_SET_VALUE,
|
||||
_service_handle_value,
|
||||
descriptions[DOMAIN][SERVICE_SET_VALUE],
|
||||
schema=SCHEMA_SERVICE_SET_VALUE)
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SET_VAR_VALUE, _service_handle_value,
|
||||
descriptions[DOMAIN][SERVICE_SET_VAR_VALUE],
|
||||
schema=SCHEMA_SERVICE_SET_VAR_VALUE)
|
||||
|
||||
def _service_handle_reconnect(service):
|
||||
"""Reconnect to all homematic hubs."""
|
||||
hass.data[DATA_HOMEMATIC].reconnect()
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect,
|
||||
descriptions[DOMAIN][SERVICE_RECONNECT],
|
||||
schema=SCHEMA_SERVICE_RECONNECT)
|
||||
|
||||
def _service_handle_device(service):
|
||||
"""Service handle set_dev_value services."""
|
||||
address = service.data.get(ATTR_ADDRESS)
|
||||
channel = service.data.get(ATTR_CHANNEL)
|
||||
param = service.data.get(ATTR_PARAM)
|
||||
value = service.data.get(ATTR_VALUE)
|
||||
|
||||
# device not found
|
||||
hmdevice = _device_from_servicecall(hass, service)
|
||||
if hmdevice is None:
|
||||
_LOGGER.error("%s not found!", address)
|
||||
return
|
||||
|
||||
# call key
|
||||
hmdevice.setValue(param, value, channel)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SET_DEV_VALUE, _service_handle_device,
|
||||
descriptions[DOMAIN][SERVICE_SET_DEV_VALUE],
|
||||
schema=SCHEMA_SERVICE_SET_DEV_VALUE)
|
||||
|
||||
return True
|
||||
|
||||
@@ -274,22 +371,36 @@ def setup(hass, config):
|
||||
def _system_callback_handler(hass, config, src, *args):
|
||||
"""Callback handler."""
|
||||
if src == 'newDevices':
|
||||
_LOGGER.debug("newDevices with: %s", str(args))
|
||||
_LOGGER.debug("newDevices with: %s", args)
|
||||
# pylint: disable=unused-variable
|
||||
(interface_id, dev_descriptions) = args
|
||||
key_dict = {}
|
||||
proxy = interface_id.split('-')[-1]
|
||||
|
||||
# device support active?
|
||||
if not hass.data[DATA_DEVINIT][proxy]:
|
||||
return
|
||||
|
||||
##
|
||||
# Get list of all keys of the devices (ignoring channels)
|
||||
key_dict = {}
|
||||
for dev in dev_descriptions:
|
||||
key_dict[dev['ADDRESS'].split(':')[0]] = True
|
||||
|
||||
##
|
||||
# remove device they allready init by HA
|
||||
tmp_devs = key_dict.copy()
|
||||
for dev in tmp_devs:
|
||||
if dev in hass.data[DATA_STORE]:
|
||||
del key_dict[dev]
|
||||
else:
|
||||
hass.data[DATA_STORE].append(dev)
|
||||
|
||||
# Register EVENTS
|
||||
# Search all device with a EVENTNODE that include data
|
||||
bound_event_callback = partial(_hm_event_handler, hass)
|
||||
bound_event_callback = partial(_hm_event_handler, hass, proxy)
|
||||
for dev in key_dict:
|
||||
if dev not in HOMEMATIC.devices:
|
||||
continue
|
||||
hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(dev)
|
||||
|
||||
hmdevice = HOMEMATIC.devices.get(dev)
|
||||
# have events?
|
||||
if len(hmdevice.EVENTNODE) > 0:
|
||||
_LOGGER.debug("Register Events from %s", dev)
|
||||
@@ -307,7 +418,8 @@ def _system_callback_handler(hass, config, src, *args):
|
||||
('sensor', DISCOVER_SENSORS),
|
||||
('climate', DISCOVER_CLIMATE)):
|
||||
# Get all devices of a specific type
|
||||
found_devices = _get_devices(discovery_type, key_dict)
|
||||
found_devices = _get_devices(
|
||||
hass, discovery_type, key_dict, proxy)
|
||||
|
||||
# When devices of this type are found
|
||||
# they are setup in HA and an event is fired
|
||||
@@ -318,12 +430,12 @@ def _system_callback_handler(hass, config, src, *args):
|
||||
}, config)
|
||||
|
||||
|
||||
def _get_devices(device_type, keys):
|
||||
def _get_devices(hass, device_type, keys, proxy):
|
||||
"""Get the Homematic devices."""
|
||||
device_arr = []
|
||||
|
||||
for key in keys:
|
||||
device = HOMEMATIC.devices[key]
|
||||
device = hass.data[DATA_HOMEMATIC].devices[proxy][key]
|
||||
class_name = device.__class__.__name__
|
||||
metadata = {}
|
||||
|
||||
@@ -357,6 +469,7 @@ def _get_devices(device_type, keys):
|
||||
device_dict = {
|
||||
CONF_PLATFORM: "homematic",
|
||||
ATTR_ADDRESS: key,
|
||||
ATTR_PROXY: proxy,
|
||||
ATTR_NAME: name,
|
||||
ATTR_CHANNEL: channel
|
||||
}
|
||||
@@ -395,28 +508,29 @@ def _create_ha_name(name, channel, param, count):
|
||||
return "{} {} {}".format(name, channel, param)
|
||||
|
||||
|
||||
def setup_hmdevice_discovery_helper(hmdevicetype, discovery_info,
|
||||
def setup_hmdevice_discovery_helper(hass, hmdevicetype, discovery_info,
|
||||
add_callback_devices):
|
||||
"""Helper to setup Homematic devices with discovery info."""
|
||||
devices = []
|
||||
for config in discovery_info[ATTR_DISCOVER_DEVICES]:
|
||||
_LOGGER.debug("Add device %s from config: %s",
|
||||
str(hmdevicetype), str(config))
|
||||
|
||||
# create object and add to HA
|
||||
new_device = hmdevicetype(config)
|
||||
new_device = hmdevicetype(hass, config)
|
||||
new_device.link_homematic()
|
||||
devices.append(new_device)
|
||||
|
||||
add_callback_devices([new_device])
|
||||
|
||||
add_callback_devices(devices)
|
||||
return True
|
||||
|
||||
|
||||
def _hm_event_handler(hass, device, caller, attribute, value):
|
||||
def _hm_event_handler(hass, proxy, device, caller, attribute, value):
|
||||
"""Handle all pyhomematic device events."""
|
||||
try:
|
||||
channel = int(device.split(":")[1])
|
||||
address = device.split(":")[0]
|
||||
hmdevice = HOMEMATIC.devices.get(address)
|
||||
hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(address)
|
||||
except (TypeError, ValueError):
|
||||
_LOGGER.error("Event handling channel convert error!")
|
||||
return
|
||||
@@ -430,64 +544,58 @@ def _hm_event_handler(hass, device, caller, attribute, value):
|
||||
|
||||
# keypress event
|
||||
if attribute in HM_PRESS_EVENTS:
|
||||
hass.bus.fire(EVENT_KEYPRESS, {
|
||||
hass.add_job(hass.bus.async_fire(EVENT_KEYPRESS, {
|
||||
ATTR_NAME: hmdevice.NAME,
|
||||
ATTR_PARAM: attribute,
|
||||
ATTR_CHANNEL: channel
|
||||
})
|
||||
}))
|
||||
return
|
||||
|
||||
# impulse event
|
||||
if attribute in HM_IMPULSE_EVENTS:
|
||||
hass.bus.fire(EVENT_KEYPRESS, {
|
||||
hass.add_job(hass.bus.async_fire(EVENT_KEYPRESS, {
|
||||
ATTR_NAME: hmdevice.NAME,
|
||||
ATTR_CHANNEL: channel
|
||||
})
|
||||
}))
|
||||
return
|
||||
|
||||
_LOGGER.warning("Event is unknown and not forwarded to HA")
|
||||
|
||||
|
||||
def _hm_service_virtualkey(call):
|
||||
"""Callback for handle virtualkey services."""
|
||||
address = call.data.get(ATTR_ADDRESS)
|
||||
channel = call.data.get(ATTR_CHANNEL)
|
||||
param = call.data.get(ATTR_PARAM)
|
||||
def _device_from_servicecall(hass, service):
|
||||
"""Extract homematic device from service call."""
|
||||
address = service.data.get(ATTR_ADDRESS)
|
||||
proxy = service.data.get(ATTR_PROXY)
|
||||
|
||||
if address not in HOMEMATIC.devices:
|
||||
_LOGGER.error("%s not found for service virtualkey!", address)
|
||||
return
|
||||
hmdevice = HOMEMATIC.devices.get(address)
|
||||
if proxy:
|
||||
return hass.data[DATA_HOMEMATIC].devices[proxy].get(address)
|
||||
|
||||
# if param exists for this device
|
||||
if hmdevice is None or param not in hmdevice.ACTIONNODE:
|
||||
_LOGGER.error("%s not datapoint in hm device %s", param, address)
|
||||
return
|
||||
|
||||
# channel exists?
|
||||
if channel in hmdevice.ACTIONNODE[param]:
|
||||
_LOGGER.error("%i is not a channel in hm device %s", channel, address)
|
||||
return
|
||||
|
||||
# call key
|
||||
hmdevice.actionNodeData(param, 1, channel)
|
||||
for _, devices in hass.data[DATA_HOMEMATIC].devices.items():
|
||||
if address in devices:
|
||||
return devices[address]
|
||||
|
||||
|
||||
class HMHub(Entity):
|
||||
"""The Homematic hub. I.e. CCU2/HomeGear."""
|
||||
|
||||
def __init__(self, variables_store, use_variables=False):
|
||||
def __init__(self, hass, component, name, use_variables):
|
||||
"""Initialize Homematic hub."""
|
||||
self.hass = hass
|
||||
self._homematic = hass.data[DATA_HOMEMATIC]
|
||||
self._component = component
|
||||
self._name = name
|
||||
self._state = STATE_UNKNOWN
|
||||
self._store = variables_store
|
||||
self._store = {}
|
||||
self._use_variables = use_variables
|
||||
|
||||
self.update()
|
||||
# load data
|
||||
self._update_hub_state()
|
||||
self._init_variables()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return 'Homematic'
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
@@ -504,11 +612,6 @@ class HMHub(Entity):
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
return "mdi:gradient"
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return true if device is available."""
|
||||
return True if HOMEMATIC is not None else False
|
||||
|
||||
def update(self):
|
||||
"""Update Hub data and all HM variables."""
|
||||
self._update_hub_state()
|
||||
@@ -517,30 +620,48 @@ class HMHub(Entity):
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATE_HUB)
|
||||
def _update_hub_state(self):
|
||||
"""Retrieve latest state."""
|
||||
if HOMEMATIC is None:
|
||||
return
|
||||
state = HOMEMATIC.getServiceMessages()
|
||||
state = self._homematic.getServiceMessages(self._name)
|
||||
self._state = STATE_UNKNOWN if state is None else len(state)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATE_VAR)
|
||||
def _update_variables_state(self):
|
||||
"""Retrive all variable data and update hmvariable states."""
|
||||
if HOMEMATIC is None or not self._use_variables:
|
||||
if not self._use_variables:
|
||||
return
|
||||
variables = HOMEMATIC.getAllSystemVariables()
|
||||
if variables is not None:
|
||||
for key, value in variables.items():
|
||||
if key in self._store:
|
||||
self._store.get(key).hm_update(value)
|
||||
|
||||
variables = self._homematic.getAllSystemVariables(self._name)
|
||||
if variables is None:
|
||||
return
|
||||
|
||||
for key, value in variables.items():
|
||||
if key in self._store:
|
||||
self._store.get(key).hm_update(value)
|
||||
|
||||
def _init_variables(self):
|
||||
"""Load variables from hub."""
|
||||
if not self._use_variables:
|
||||
return
|
||||
|
||||
variables = self._homematic.getAllSystemVariables(self._name)
|
||||
if variables is None:
|
||||
return
|
||||
|
||||
entities = []
|
||||
for key, value in variables.items():
|
||||
entities.append(HMVariable(self.hass, self._name, key, value))
|
||||
self._component.add_entities(entities)
|
||||
|
||||
|
||||
class HMVariable(Entity):
|
||||
"""The Homematic system variable."""
|
||||
|
||||
def __init__(self, name, state):
|
||||
def __init__(self, hass, hub_name, name, state):
|
||||
"""Initialize Homematic hub."""
|
||||
self.hass = hass
|
||||
self._homematic = hass.data[DATA_HOMEMATIC]
|
||||
self._state = state
|
||||
self._name = name
|
||||
self._hub_name = hub_name
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -562,31 +683,41 @@ class HMVariable(Entity):
|
||||
"""Return false. Homematic Hub object update variable."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return device specific state attributes."""
|
||||
attr = {
|
||||
'hub': self._hub_name,
|
||||
}
|
||||
return attr
|
||||
|
||||
def hm_update(self, value):
|
||||
"""Update variable over Hub object."""
|
||||
if value != self._state:
|
||||
self._state = value
|
||||
self.update_ha_state()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def hm_set(self, value):
|
||||
"""Set variable on homematic controller."""
|
||||
if HOMEMATIC is not None:
|
||||
if isinstance(self._state, bool):
|
||||
value = cv.boolean(value)
|
||||
else:
|
||||
value = float(value)
|
||||
HOMEMATIC.setSystemVariable(self._name, value)
|
||||
self._state = value
|
||||
self.update_ha_state()
|
||||
if isinstance(self._state, bool):
|
||||
value = cv.boolean(value)
|
||||
else:
|
||||
value = float(value)
|
||||
self._homematic.setSystemVariable(self._hub_name, self._name, value)
|
||||
self._state = value
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
|
||||
class HMDevice(Entity):
|
||||
"""The Homematic device base object."""
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, hass, config):
|
||||
"""Initialize a generic Homematic device."""
|
||||
self.hass = hass
|
||||
self._homematic = hass.data[DATA_HOMEMATIC]
|
||||
self._name = config.get(ATTR_NAME)
|
||||
self._address = config.get(ATTR_ADDRESS)
|
||||
self._proxy = config.get(ATTR_PROXY)
|
||||
self._channel = config.get(ATTR_CHANNEL)
|
||||
self._state = config.get(ATTR_PARAM)
|
||||
self._data = {}
|
||||
@@ -636,6 +767,7 @@ class HMDevice(Entity):
|
||||
|
||||
# static attributes
|
||||
attr['ID'] = self._hmdevice.ADDRESS
|
||||
attr['proxy'] = self._proxy
|
||||
|
||||
return attr
|
||||
|
||||
@@ -645,39 +777,31 @@ class HMDevice(Entity):
|
||||
if self._connected:
|
||||
return True
|
||||
|
||||
# pyhomematic is loaded
|
||||
if HOMEMATIC is None:
|
||||
return False
|
||||
# Init
|
||||
self._hmdevice = self._homematic.devices[self._proxy][self._address]
|
||||
self._connected = True
|
||||
|
||||
# Does a HMDevice from pyhomematic exist?
|
||||
if self._address in HOMEMATIC.devices:
|
||||
# Init
|
||||
self._hmdevice = HOMEMATIC.devices[self._address]
|
||||
self._connected = True
|
||||
# Check if Homematic class is okay for HA class
|
||||
_LOGGER.info("Start linking %s to %s", self._address, self._name)
|
||||
try:
|
||||
# Init datapoints of this object
|
||||
self._init_data()
|
||||
if self.hass.data[DATA_DELAY]:
|
||||
# We delay / pause loading of data to avoid overloading
|
||||
# of CCU / Homegear when doing auto detection
|
||||
time.sleep(self.hass.data[DATA_DELAY])
|
||||
self._load_data_from_hm()
|
||||
_LOGGER.debug("%s datastruct: %s", self._name, str(self._data))
|
||||
|
||||
# Check if Homematic class is okay for HA class
|
||||
_LOGGER.info("Start linking %s to %s", self._address, self._name)
|
||||
try:
|
||||
# Init datapoints of this object
|
||||
self._init_data()
|
||||
if HOMEMATIC_LINK_DELAY:
|
||||
# We delay / pause loading of data to avoid overloading
|
||||
# of CCU / Homegear when doing auto detection
|
||||
time.sleep(HOMEMATIC_LINK_DELAY)
|
||||
self._load_data_from_hm()
|
||||
_LOGGER.debug("%s datastruct: %s", self._name, str(self._data))
|
||||
|
||||
# Link events from pyhomatic
|
||||
self._subscribe_homematic_events()
|
||||
self._available = not self._hmdevice.UNREACH
|
||||
_LOGGER.debug("%s linking done", self._name)
|
||||
# pylint: disable=broad-except
|
||||
except Exception as err:
|
||||
self._connected = False
|
||||
_LOGGER.error("Exception while linking %s: %s",
|
||||
self._address, str(err))
|
||||
else:
|
||||
_LOGGER.debug("%s not found in HOMEMATIC.devices", self._address)
|
||||
# Link events from pyhomatic
|
||||
self._subscribe_homematic_events()
|
||||
self._available = not self._hmdevice.UNREACH
|
||||
_LOGGER.debug("%s linking done", self._name)
|
||||
# pylint: disable=broad-except
|
||||
except Exception as err:
|
||||
self._connected = False
|
||||
_LOGGER.error("Exception while linking %s: %s",
|
||||
self._address, str(err))
|
||||
|
||||
def _hm_event_callback(self, device, caller, attribute, value):
|
||||
"""Handle all pyhomematic device events."""
|
||||
@@ -701,7 +825,7 @@ class HMDevice(Entity):
|
||||
if have_change:
|
||||
_LOGGER.debug("%s update_ha_state after '%s'", self._name,
|
||||
attribute)
|
||||
self.update_ha_state()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def _subscribe_homematic_events(self):
|
||||
"""Subscribe all required events to handle job."""
|
||||
|
||||
@@ -5,35 +5,38 @@ For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/http/
|
||||
"""
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ssl
|
||||
from ipaddress import ip_address, ip_network
|
||||
from ipaddress import ip_network
|
||||
from pathlib import Path
|
||||
|
||||
import os
|
||||
import voluptuous as vol
|
||||
from aiohttp import web, hdrs
|
||||
from aiohttp.file_sender import FileSender
|
||||
from aiohttp.web_exceptions import (
|
||||
HTTPUnauthorized, HTTPMovedPermanently, HTTPNotModified)
|
||||
from aiohttp.web_urldispatcher import StaticRoute
|
||||
from aiohttp import web
|
||||
from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently
|
||||
|
||||
from homeassistant.core import is_callback
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant import util
|
||||
from homeassistant.const import (
|
||||
SERVER_PORT, HTTP_HEADER_HA_AUTH, # HTTP_HEADER_CACHE_CONTROL,
|
||||
CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS, EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_HOMEASSISTANT_START, HTTP_HEADER_X_FORWARDED_FOR)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.util import get_local_ip
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.const import (
|
||||
SERVER_PORT, CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS,
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
|
||||
from homeassistant.core import is_callback
|
||||
from homeassistant.util.logging import HideSensitiveDataFilter
|
||||
|
||||
from .auth import auth_middleware
|
||||
from .ban import ban_middleware, process_wrong_login
|
||||
from .const import (
|
||||
KEY_USE_X_FORWARDED_FOR, KEY_TRUSTED_NETWORKS,
|
||||
KEY_BANS_ENABLED, KEY_LOGIN_THRESHOLD,
|
||||
KEY_DEVELOPMENT, KEY_AUTHENTICATED)
|
||||
from .static import FILE_SENDER, GZIP_FILE_SENDER, staticresource_middleware
|
||||
from .util import get_real_ip
|
||||
|
||||
DOMAIN = 'http'
|
||||
REQUIREMENTS = ('aiohttp_cors==0.4.0',)
|
||||
REQUIREMENTS = ('aiohttp_cors==0.5.0',)
|
||||
|
||||
CONF_API_PASSWORD = 'api_password'
|
||||
CONF_SERVER_HOST = 'server_host'
|
||||
@@ -44,8 +47,9 @@ CONF_SSL_KEY = 'ssl_key'
|
||||
CONF_CORS_ORIGINS = 'cors_allowed_origins'
|
||||
CONF_USE_X_FORWARDED_FOR = 'use_x_forwarded_for'
|
||||
CONF_TRUSTED_NETWORKS = 'trusted_networks'
|
||||
CONF_LOGIN_ATTEMPTS_THRESHOLD = 'login_attempts_threshold'
|
||||
CONF_IP_BAN_ENABLED = 'ip_ban_enabled'
|
||||
|
||||
DATA_API_PASSWORD = 'api_password'
|
||||
NOTIFICATION_ID_LOGIN = 'http-login'
|
||||
|
||||
# TLS configuation follows the best-practice guidelines specified here:
|
||||
@@ -69,68 +73,58 @@ CIPHERS = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" \
|
||||
"AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:" \
|
||||
"AES256-SHA:DES-CBC3-SHA:!DSS"
|
||||
|
||||
_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SERVER_HOST = '0.0.0.0'
|
||||
DEFAULT_DEVELOPMENT = '0'
|
||||
DEFAULT_LOGIN_ATTEMPT_THRESHOLD = -1
|
||||
|
||||
HTTP_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_API_PASSWORD, default=None): cv.string,
|
||||
vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string,
|
||||
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
|
||||
vol.Optional(CONF_DEVELOPMENT, default=DEFAULT_DEVELOPMENT): cv.string,
|
||||
vol.Optional(CONF_SSL_CERTIFICATE, default=None): cv.isfile,
|
||||
vol.Optional(CONF_SSL_KEY, default=None): cv.isfile,
|
||||
vol.Optional(CONF_CORS_ORIGINS, default=[]): vol.All(cv.ensure_list,
|
||||
[cv.string]),
|
||||
vol.Optional(CONF_USE_X_FORWARDED_FOR, default=False): cv.boolean,
|
||||
vol.Optional(CONF_TRUSTED_NETWORKS, default=[]):
|
||||
vol.All(cv.ensure_list, [ip_network]),
|
||||
vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD,
|
||||
default=DEFAULT_LOGIN_ATTEMPT_THRESHOLD): cv.positive_int,
|
||||
vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_API_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_SERVER_HOST): cv.string,
|
||||
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
|
||||
vol.Optional(CONF_DEVELOPMENT): cv.string,
|
||||
vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
|
||||
vol.Optional(CONF_SSL_KEY): cv.isfile,
|
||||
vol.Optional(CONF_CORS_ORIGINS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_USE_X_FORWARDED_FOR, default=False): cv.boolean,
|
||||
vol.Optional(CONF_TRUSTED_NETWORKS):
|
||||
vol.All(cv.ensure_list, [ip_network])
|
||||
}),
|
||||
DOMAIN: HTTP_SCHEMA,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
# TEMP TO GET TESTS TO RUN
|
||||
def request_class():
|
||||
"""."""
|
||||
raise Exception('not implemented')
|
||||
|
||||
|
||||
class HideSensitiveFilter(logging.Filter):
|
||||
"""Filter API password calls."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize sensitive data filter."""
|
||||
super().__init__()
|
||||
self.hass = hass
|
||||
|
||||
def filter(self, record):
|
||||
"""Hide sensitive data in messages."""
|
||||
if self.hass.http.api_password is None:
|
||||
return True
|
||||
|
||||
record.msg = record.msg.replace(self.hass.http.api_password, '*******')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Set up the HTTP API and debug interface."""
|
||||
logging.getLogger('aiohttp.access').addFilter(HideSensitiveFilter(hass))
|
||||
conf = config.get(DOMAIN)
|
||||
|
||||
conf = config.get(DOMAIN, {})
|
||||
if conf is None:
|
||||
conf = HTTP_SCHEMA({})
|
||||
|
||||
api_password = util.convert(conf.get(CONF_API_PASSWORD), str)
|
||||
server_host = conf.get(CONF_SERVER_HOST, '0.0.0.0')
|
||||
server_port = conf.get(CONF_SERVER_PORT, SERVER_PORT)
|
||||
development = str(conf.get(CONF_DEVELOPMENT, '')) == '1'
|
||||
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
|
||||
ssl_key = conf.get(CONF_SSL_KEY)
|
||||
cors_origins = conf.get(CONF_CORS_ORIGINS, [])
|
||||
use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False)
|
||||
trusted_networks = [
|
||||
ip_network(trusted_network)
|
||||
for trusted_network in conf.get(CONF_TRUSTED_NETWORKS, [])]
|
||||
api_password = conf[CONF_API_PASSWORD]
|
||||
server_host = conf[CONF_SERVER_HOST]
|
||||
server_port = conf[CONF_SERVER_PORT]
|
||||
development = conf[CONF_DEVELOPMENT] == '1'
|
||||
ssl_certificate = conf[CONF_SSL_CERTIFICATE]
|
||||
ssl_key = conf[CONF_SSL_KEY]
|
||||
cors_origins = conf[CONF_CORS_ORIGINS]
|
||||
use_x_forwarded_for = conf[CONF_USE_X_FORWARDED_FOR]
|
||||
trusted_networks = conf[CONF_TRUSTED_NETWORKS]
|
||||
is_ban_enabled = conf[CONF_IP_BAN_ENABLED]
|
||||
login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD]
|
||||
|
||||
if api_password is not None:
|
||||
logging.getLogger('aiohttp.access').addFilter(
|
||||
HideSensitiveDataFilter(api_password))
|
||||
|
||||
server = HomeAssistantWSGI(
|
||||
hass,
|
||||
@@ -142,7 +136,9 @@ def setup(hass, config):
|
||||
ssl_key=ssl_key,
|
||||
cors_origins=cors_origins,
|
||||
use_x_forwarded_for=use_x_forwarded_for,
|
||||
trusted_networks=trusted_networks
|
||||
trusted_networks=trusted_networks,
|
||||
login_threshold=login_threshold,
|
||||
is_ban_enabled=is_ban_enabled
|
||||
)
|
||||
|
||||
@asyncio.coroutine
|
||||
@@ -156,108 +152,40 @@ def setup(hass, config):
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
|
||||
yield from server.start()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_server)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server)
|
||||
|
||||
hass.http = server
|
||||
hass.config.api = rem.API(server_host if server_host != '0.0.0.0'
|
||||
else util.get_local_ip(),
|
||||
else get_local_ip(),
|
||||
api_password, server_port,
|
||||
ssl_certificate is not None)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class GzipFileSender(FileSender):
|
||||
"""FileSender class capable of sending gzip version if available."""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
development = False
|
||||
|
||||
@asyncio.coroutine
|
||||
def send(self, request, filepath):
|
||||
"""Send filepath to client using request."""
|
||||
gzip = False
|
||||
if 'gzip' in request.headers[hdrs.ACCEPT_ENCODING]:
|
||||
gzip_path = filepath.with_name(filepath.name + '.gz')
|
||||
|
||||
if gzip_path.is_file():
|
||||
filepath = gzip_path
|
||||
gzip = True
|
||||
|
||||
st = filepath.stat()
|
||||
|
||||
modsince = request.if_modified_since
|
||||
if modsince is not None and st.st_mtime <= modsince.timestamp():
|
||||
raise HTTPNotModified()
|
||||
|
||||
ct, encoding = mimetypes.guess_type(str(filepath))
|
||||
if not ct:
|
||||
ct = 'application/octet-stream'
|
||||
|
||||
resp = self._response_factory()
|
||||
resp.content_type = ct
|
||||
if encoding:
|
||||
resp.headers[hdrs.CONTENT_ENCODING] = encoding
|
||||
if gzip:
|
||||
resp.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
|
||||
resp.last_modified = st.st_mtime
|
||||
|
||||
# CACHE HACK
|
||||
if not self.development:
|
||||
cache_time = 31 * 86400 # = 1 month
|
||||
resp.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format(
|
||||
cache_time)
|
||||
|
||||
file_size = st.st_size
|
||||
|
||||
resp.content_length = file_size
|
||||
resp.set_tcp_cork(True)
|
||||
try:
|
||||
with filepath.open('rb') as f:
|
||||
yield from self._sendfile(request, resp, f, file_size)
|
||||
|
||||
finally:
|
||||
resp.set_tcp_nodelay(True)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
_GZIP_FILE_SENDER = GzipFileSender()
|
||||
|
||||
|
||||
class HAStaticRoute(StaticRoute):
|
||||
"""StaticRoute with support for fingerprinting."""
|
||||
|
||||
def __init__(self, prefix, path):
|
||||
"""Initialize a static route with gzip and cache busting support."""
|
||||
super().__init__(None, prefix, path)
|
||||
self._file_sender = _GZIP_FILE_SENDER
|
||||
|
||||
def match(self, path):
|
||||
"""Match path to filename."""
|
||||
if not path.startswith(self._prefix):
|
||||
return None
|
||||
|
||||
# Extra sauce to remove fingerprinted resource names
|
||||
filename = path[self._prefix_len:]
|
||||
fingerprinted = _FINGERPRINT.match(filename)
|
||||
if fingerprinted:
|
||||
filename = '{}.{}'.format(*fingerprinted.groups())
|
||||
|
||||
return {'filename': filename}
|
||||
|
||||
|
||||
class HomeAssistantWSGI(object):
|
||||
"""WSGI server for Home Assistant."""
|
||||
|
||||
def __init__(self, hass, development, api_password, ssl_certificate,
|
||||
ssl_key, server_host, server_port, cors_origins,
|
||||
use_x_forwarded_for, trusted_networks):
|
||||
use_x_forwarded_for, trusted_networks,
|
||||
login_threshold, is_ban_enabled):
|
||||
"""Initialize the WSGI Home Assistant server."""
|
||||
import aiohttp_cors
|
||||
|
||||
self.app = web.Application(loop=hass.loop)
|
||||
middlewares = [auth_middleware, staticresource_middleware]
|
||||
|
||||
if is_ban_enabled:
|
||||
middlewares.insert(0, ban_middleware)
|
||||
|
||||
self.app = web.Application(middlewares=middlewares, loop=hass.loop)
|
||||
self.app['hass'] = hass
|
||||
self.app[KEY_USE_X_FORWARDED_FOR] = use_x_forwarded_for
|
||||
self.app[KEY_TRUSTED_NETWORKS] = trusted_networks
|
||||
self.app[KEY_BANS_ENABLED] = is_ban_enabled
|
||||
self.app[KEY_LOGIN_THRESHOLD] = login_threshold
|
||||
self.app[KEY_DEVELOPMENT] = development
|
||||
|
||||
self.hass = hass
|
||||
self.development = development
|
||||
self.api_password = api_password
|
||||
@@ -265,9 +193,6 @@ class HomeAssistantWSGI(object):
|
||||
self.ssl_key = ssl_key
|
||||
self.server_host = server_host
|
||||
self.server_port = server_port
|
||||
self.use_x_forwarded_for = use_x_forwarded_for
|
||||
self.trusted_networks = trusted_networks
|
||||
self.event_forwarder = None
|
||||
self._handler = None
|
||||
self.server = None
|
||||
|
||||
@@ -281,9 +206,6 @@ class HomeAssistantWSGI(object):
|
||||
else:
|
||||
self.cors = None
|
||||
|
||||
# CACHE HACK
|
||||
_GZIP_FILE_SENDER.development = development
|
||||
|
||||
def register_view(self, view):
|
||||
"""Register a view with the WSGI server.
|
||||
|
||||
@@ -293,7 +215,19 @@ class HomeAssistantWSGI(object):
|
||||
"""
|
||||
if isinstance(view, type):
|
||||
# Instantiate the view, if needed
|
||||
view = view(self.hass)
|
||||
view = view()
|
||||
|
||||
if not hasattr(view, 'url'):
|
||||
class_name = view.__class__.__name__
|
||||
raise AttributeError(
|
||||
'{0} missing required attribute "url"'.format(class_name)
|
||||
)
|
||||
|
||||
if not hasattr(view, 'name'):
|
||||
class_name = view.__class__.__name__
|
||||
raise AttributeError(
|
||||
'{0} missing required attribute "name"'.format(class_name)
|
||||
)
|
||||
|
||||
view.register(self.app.router)
|
||||
|
||||
@@ -318,19 +252,15 @@ class HomeAssistantWSGI(object):
|
||||
Specify optional cache length of asset in days.
|
||||
"""
|
||||
if os.path.isdir(path):
|
||||
assert url_root.startswith('/')
|
||||
if not url_root.endswith('/'):
|
||||
url_root += '/'
|
||||
route = HAStaticRoute(url_root, path)
|
||||
self.app.router.register_route(route)
|
||||
self.app.router.add_static(url_root, path)
|
||||
return
|
||||
|
||||
filepath = Path(path)
|
||||
|
||||
@asyncio.coroutine
|
||||
def serve_file(request):
|
||||
"""Redirect to location."""
|
||||
res = yield from _GZIP_FILE_SENDER.send(request, filepath)
|
||||
"""Serve file from disk."""
|
||||
res = yield from GZIP_FILE_SENDER.send(request, filepath)
|
||||
return res
|
||||
|
||||
# aiohttp supports regex matching for variables. Using that as temp
|
||||
@@ -347,46 +277,60 @@ class HomeAssistantWSGI(object):
|
||||
@asyncio.coroutine
|
||||
def start(self):
|
||||
"""Start the wsgi server."""
|
||||
cors_added = set()
|
||||
if self.cors is not None:
|
||||
for route in list(self.app.router.routes()):
|
||||
if hasattr(route, 'resource'):
|
||||
route = route.resource
|
||||
if route in cors_added:
|
||||
continue
|
||||
self.cors.add(route)
|
||||
cors_added.add(route)
|
||||
|
||||
if self.ssl_certificate:
|
||||
context = ssl.SSLContext(SSL_VERSION)
|
||||
context.options |= SSL_OPTS
|
||||
context.set_ciphers(CIPHERS)
|
||||
context.load_cert_chain(self.ssl_certificate, self.ssl_key)
|
||||
try:
|
||||
context = ssl.SSLContext(SSL_VERSION)
|
||||
context.options |= SSL_OPTS
|
||||
context.set_ciphers(CIPHERS)
|
||||
context.load_cert_chain(self.ssl_certificate, self.ssl_key)
|
||||
except OSError as error:
|
||||
_LOGGER.error("Could not read SSL certificate from %s: %s",
|
||||
self.ssl_certificate, error)
|
||||
context = None
|
||||
return
|
||||
else:
|
||||
context = None
|
||||
|
||||
# Aiohttp freezes apps after start so that no changes can be made.
|
||||
# However in Home Assistant components can be discovered after boot.
|
||||
# This will now raise a RunTimeError.
|
||||
# To work around this we now fake that we are frozen.
|
||||
# A more appropriate fix would be to create a new app and
|
||||
# re-register all redirects, views, static paths.
|
||||
self.app._frozen = True # pylint: disable=protected-access
|
||||
|
||||
self._handler = self.app.make_handler()
|
||||
self.server = yield from self.hass.loop.create_server(
|
||||
self._handler, self.server_host, self.server_port, ssl=context)
|
||||
|
||||
try:
|
||||
self.server = yield from self.hass.loop.create_server(
|
||||
self._handler, self.server_host, self.server_port, ssl=context)
|
||||
except OSError as error:
|
||||
_LOGGER.error("Failed to create HTTP server at port %d: %s",
|
||||
self.server_port, error)
|
||||
|
||||
self.app._frozen = False # pylint: disable=protected-access
|
||||
|
||||
@asyncio.coroutine
|
||||
def stop(self):
|
||||
"""Stop the wsgi server."""
|
||||
self.server.close()
|
||||
yield from self.server.wait_closed()
|
||||
if self.server:
|
||||
self.server.close()
|
||||
yield from self.server.wait_closed()
|
||||
yield from self.app.shutdown()
|
||||
yield from self._handler.finish_connections(60.0)
|
||||
if self._handler:
|
||||
yield from self._handler.finish_connections(60.0)
|
||||
yield from self.app.cleanup()
|
||||
|
||||
def get_real_ip(self, request):
|
||||
"""Return the clients correct ip address, even in proxied setups."""
|
||||
if self.use_x_forwarded_for \
|
||||
and HTTP_HEADER_X_FORWARDED_FOR in request.headers:
|
||||
return request.headers.get(
|
||||
HTTP_HEADER_X_FORWARDED_FOR).split(',')[0]
|
||||
else:
|
||||
peername = request.transport.get_extra_info('peername')
|
||||
return peername[0] if peername is not None else None
|
||||
|
||||
def is_trusted_ip(self, remote_addr):
|
||||
"""Match an ip address against trusted CIDR networks."""
|
||||
return any(ip_address(remote_addr) in trusted_network
|
||||
for trusted_network in self.hass.http.trusted_networks)
|
||||
|
||||
|
||||
class HomeAssistantView(object):
|
||||
"""Base view for all views."""
|
||||
@@ -395,22 +339,6 @@ class HomeAssistantView(object):
|
||||
extra_urls = []
|
||||
requires_auth = True # Views inheriting from this class can override this
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initilalize the base view."""
|
||||
if not hasattr(self, 'url'):
|
||||
class_name = self.__class__.__name__
|
||||
raise AttributeError(
|
||||
'{0} missing required attribute "url"'.format(class_name)
|
||||
)
|
||||
|
||||
if not hasattr(self, 'name'):
|
||||
class_name = self.__class__.__name__
|
||||
raise AttributeError(
|
||||
'{0} missing required attribute "name"'.format(class_name)
|
||||
)
|
||||
|
||||
self.hass = hass
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def json(self, result, status_code=200):
|
||||
"""Return a JSON response."""
|
||||
@@ -428,7 +356,7 @@ class HomeAssistantView(object):
|
||||
def file(self, request, fil):
|
||||
"""Return a file."""
|
||||
assert isinstance(fil, str), 'only string paths allowed'
|
||||
response = yield from _GZIP_FILE_SENDER.send(request, Path(fil))
|
||||
response = yield from FILE_SENDER.send(request, Path(fil))
|
||||
return response
|
||||
|
||||
def register(self, router):
|
||||
@@ -455,53 +383,32 @@ class HomeAssistantView(object):
|
||||
|
||||
|
||||
def request_handler_factory(view, handler):
|
||||
"""Factory to wrap our handler classes.
|
||||
"""Factory to wrap our handler classes."""
|
||||
assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \
|
||||
"Handler should be a coroutine or a callback."
|
||||
|
||||
Eventually authentication should be managed by middleware.
|
||||
"""
|
||||
@asyncio.coroutine
|
||||
def handle(request):
|
||||
"""Handle incoming request."""
|
||||
if not view.hass.is_running:
|
||||
if not request.app['hass'].is_running:
|
||||
return web.Response(status=503)
|
||||
|
||||
remote_addr = view.hass.http.get_real_ip(request)
|
||||
|
||||
# Auth code verbose on purpose
|
||||
authenticated = False
|
||||
|
||||
if view.hass.http.api_password is None:
|
||||
authenticated = True
|
||||
|
||||
elif view.hass.http.is_trusted_ip(remote_addr):
|
||||
authenticated = True
|
||||
|
||||
elif hmac.compare_digest(request.headers.get(HTTP_HEADER_HA_AUTH, ''),
|
||||
view.hass.http.api_password):
|
||||
# A valid auth header has been set
|
||||
authenticated = True
|
||||
|
||||
elif hmac.compare_digest(request.GET.get(DATA_API_PASSWORD, ''),
|
||||
view.hass.http.api_password):
|
||||
authenticated = True
|
||||
remote_addr = get_real_ip(request)
|
||||
authenticated = request.get(KEY_AUTHENTICATED, False)
|
||||
|
||||
if view.requires_auth and not authenticated:
|
||||
yield from process_wrong_login(request)
|
||||
_LOGGER.warning('Login attempt or request with an invalid '
|
||||
'password from %s', remote_addr)
|
||||
persistent_notification.async_create(
|
||||
view.hass,
|
||||
request.app['hass'],
|
||||
'Invalid password used from {}'.format(remote_addr),
|
||||
'Login attempt failed', NOTIFICATION_ID_LOGIN)
|
||||
raise HTTPUnauthorized()
|
||||
|
||||
request.authenticated = authenticated
|
||||
|
||||
_LOGGER.info('Serving %s to %s (auth: %s)',
|
||||
request.path, remote_addr, authenticated)
|
||||
|
||||
assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \
|
||||
"Handler should be a coroutine or a callback."
|
||||
|
||||
result = handler(request, **request.match_info)
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
66
homeassistant/components/http/auth.py
Normal file
66
homeassistant/components/http/auth.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Authentication for HTTP component."""
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
|
||||
from homeassistant.const import HTTP_HEADER_HA_AUTH
|
||||
from .util import get_real_ip
|
||||
from .const import KEY_TRUSTED_NETWORKS, KEY_AUTHENTICATED
|
||||
|
||||
DATA_API_PASSWORD = 'api_password'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def auth_middleware(app, handler):
|
||||
"""Authentication middleware."""
|
||||
# If no password set, just always set authenticated=True
|
||||
if app['hass'].http.api_password is None:
|
||||
@asyncio.coroutine
|
||||
def no_auth_middleware_handler(request):
|
||||
"""Auth middleware to approve all requests."""
|
||||
request[KEY_AUTHENTICATED] = True
|
||||
return handler(request)
|
||||
|
||||
return no_auth_middleware_handler
|
||||
|
||||
@asyncio.coroutine
|
||||
def auth_middleware_handler(request):
|
||||
"""Auth middleware to check authentication."""
|
||||
# Auth code verbose on purpose
|
||||
authenticated = False
|
||||
|
||||
if (HTTP_HEADER_HA_AUTH in request.headers and
|
||||
validate_password(request,
|
||||
request.headers[HTTP_HEADER_HA_AUTH])):
|
||||
# A valid auth header has been set
|
||||
authenticated = True
|
||||
|
||||
elif (DATA_API_PASSWORD in request.GET and
|
||||
validate_password(request, request.GET[DATA_API_PASSWORD])):
|
||||
authenticated = True
|
||||
|
||||
elif is_trusted_ip(request):
|
||||
authenticated = True
|
||||
|
||||
request[KEY_AUTHENTICATED] = authenticated
|
||||
|
||||
return handler(request)
|
||||
|
||||
return auth_middleware_handler
|
||||
|
||||
|
||||
def is_trusted_ip(request):
|
||||
"""Test if request is from a trusted ip."""
|
||||
ip_addr = get_real_ip(request)
|
||||
|
||||
return ip_addr and any(
|
||||
ip_addr in trusted_network for trusted_network
|
||||
in request.app[KEY_TRUSTED_NETWORKS])
|
||||
|
||||
|
||||
def validate_password(request, api_password):
|
||||
"""Test if password is valid."""
|
||||
return hmac.compare_digest(api_password,
|
||||
request.app['hass'].http.api_password)
|
||||
132
homeassistant/components/http/ban.py
Normal file
132
homeassistant/components/http/ban.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Ban logic for HTTP component."""
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from ipaddress import ip_address
|
||||
import logging
|
||||
|
||||
from aiohttp.web_exceptions import HTTPForbidden
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util.yaml import dump
|
||||
from .const import (
|
||||
KEY_BANS_ENABLED, KEY_BANNED_IPS, KEY_LOGIN_THRESHOLD,
|
||||
KEY_FAILED_LOGIN_ATTEMPTS)
|
||||
from .util import get_real_ip
|
||||
|
||||
NOTIFICATION_ID_BAN = 'ip-ban'
|
||||
|
||||
IP_BANS_FILE = 'ip_bans.yaml'
|
||||
ATTR_BANNED_AT = "banned_at"
|
||||
|
||||
SCHEMA_IP_BAN_ENTRY = vol.Schema({
|
||||
vol.Optional('banned_at'): vol.Any(None, cv.datetime)
|
||||
})
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def ban_middleware(app, handler):
|
||||
"""IP Ban middleware."""
|
||||
if not app[KEY_BANS_ENABLED]:
|
||||
return handler
|
||||
|
||||
if KEY_BANNED_IPS not in app:
|
||||
hass = app['hass']
|
||||
app[KEY_BANNED_IPS] = yield from hass.loop.run_in_executor(
|
||||
None, load_ip_bans_config, hass.config.path(IP_BANS_FILE))
|
||||
|
||||
@asyncio.coroutine
|
||||
def ban_middleware_handler(request):
|
||||
"""Verify if IP is not banned."""
|
||||
ip_address_ = get_real_ip(request)
|
||||
|
||||
is_banned = any(ip_ban.ip_address == ip_address_
|
||||
for ip_ban in request.app[KEY_BANNED_IPS])
|
||||
|
||||
if is_banned:
|
||||
raise HTTPForbidden()
|
||||
|
||||
return handler(request)
|
||||
|
||||
return ban_middleware_handler
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def process_wrong_login(request):
|
||||
"""Process a wrong login attempt."""
|
||||
if (not request.app[KEY_BANS_ENABLED] or
|
||||
request.app[KEY_LOGIN_THRESHOLD] < 1):
|
||||
return
|
||||
|
||||
if KEY_FAILED_LOGIN_ATTEMPTS not in request.app:
|
||||
request.app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int)
|
||||
|
||||
remote_addr = get_real_ip(request)
|
||||
|
||||
request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1
|
||||
|
||||
if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] >
|
||||
request.app[KEY_LOGIN_THRESHOLD]):
|
||||
new_ban = IpBan(remote_addr)
|
||||
request.app[KEY_BANNED_IPS].append(new_ban)
|
||||
|
||||
hass = request.app['hass']
|
||||
yield from hass.loop.run_in_executor(
|
||||
None, update_ip_bans_config, hass.config.path(IP_BANS_FILE),
|
||||
new_ban)
|
||||
|
||||
_LOGGER.warning('Banned IP %s for too many login attempts',
|
||||
remote_addr)
|
||||
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
'Too many login attempts from {}'.format(remote_addr),
|
||||
'Banning IP address', NOTIFICATION_ID_BAN)
|
||||
|
||||
|
||||
class IpBan(object):
|
||||
"""Represents banned IP address."""
|
||||
|
||||
def __init__(self, ip_ban: str, banned_at: datetime=None) -> None:
|
||||
"""Initializing Ip Ban object."""
|
||||
self.ip_address = ip_address(ip_ban)
|
||||
self.banned_at = banned_at or datetime.utcnow()
|
||||
|
||||
|
||||
def load_ip_bans_config(path: str):
|
||||
"""Loading list of banned IPs from config file."""
|
||||
ip_list = []
|
||||
|
||||
try:
|
||||
list_ = load_yaml_config_file(path)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error('Unable to load %s: %s', path, str(err))
|
||||
return []
|
||||
|
||||
for ip_ban, ip_info in list_.items():
|
||||
try:
|
||||
ip_info = SCHEMA_IP_BAN_ENTRY(ip_info)
|
||||
ip_list.append(IpBan(ip_ban, ip_info['banned_at']))
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Failed to load IP ban %s: %s', ip_info, err)
|
||||
continue
|
||||
|
||||
return ip_list
|
||||
|
||||
|
||||
def update_ip_bans_config(path: str, ip_ban: IpBan):
|
||||
"""Update config file with new banned IP address."""
|
||||
with open(path, 'a') as out:
|
||||
ip_ = {str(ip_ban.ip_address): {
|
||||
ATTR_BANNED_AT: ip_ban.banned_at.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
}}
|
||||
out.write('\n')
|
||||
out.write(dump(ip_))
|
||||
12
homeassistant/components/http/const.py
Normal file
12
homeassistant/components/http/const.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""HTTP specific constants."""
|
||||
KEY_AUTHENTICATED = 'ha_authenticated'
|
||||
KEY_USE_X_FORWARDED_FOR = 'ha_use_x_forwarded_for'
|
||||
KEY_TRUSTED_NETWORKS = 'ha_trusted_networks'
|
||||
KEY_REAL_IP = 'ha_real_ip'
|
||||
KEY_BANS_ENABLED = 'ha_bans_enabled'
|
||||
KEY_BANNED_IPS = 'ha_banned_ips'
|
||||
KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts'
|
||||
KEY_LOGIN_THRESHOLD = 'ha_login_treshold'
|
||||
KEY_DEVELOPMENT = 'ha_development'
|
||||
|
||||
HTTP_HEADER_X_FORWARDED_FOR = 'X-Forwarded-For'
|
||||
94
homeassistant/components/http/static.py
Normal file
94
homeassistant/components/http/static.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Static file handling for HTTP component."""
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import re
|
||||
|
||||
from aiohttp import hdrs
|
||||
from aiohttp.file_sender import FileSender
|
||||
from aiohttp.web_urldispatcher import StaticResource
|
||||
from aiohttp.web_exceptions import HTTPNotModified
|
||||
|
||||
from .const import KEY_DEVELOPMENT
|
||||
|
||||
_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE)
|
||||
|
||||
|
||||
class GzipFileSender(FileSender):
|
||||
"""FileSender class capable of sending gzip version if available."""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
@asyncio.coroutine
|
||||
def send(self, request, filepath):
|
||||
"""Send filepath to client using request."""
|
||||
gzip = False
|
||||
if 'gzip' in request.headers[hdrs.ACCEPT_ENCODING]:
|
||||
gzip_path = filepath.with_name(filepath.name + '.gz')
|
||||
|
||||
if gzip_path.is_file():
|
||||
filepath = gzip_path
|
||||
gzip = True
|
||||
|
||||
st = filepath.stat()
|
||||
|
||||
modsince = request.if_modified_since
|
||||
if modsince is not None and st.st_mtime <= modsince.timestamp():
|
||||
raise HTTPNotModified()
|
||||
|
||||
ct, encoding = mimetypes.guess_type(str(filepath))
|
||||
if not ct:
|
||||
ct = 'application/octet-stream'
|
||||
|
||||
resp = self._response_factory()
|
||||
resp.content_type = ct
|
||||
if encoding:
|
||||
resp.headers[hdrs.CONTENT_ENCODING] = encoding
|
||||
if gzip:
|
||||
resp.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
|
||||
resp.last_modified = st.st_mtime
|
||||
|
||||
# CACHE HACK
|
||||
if not request.app[KEY_DEVELOPMENT]:
|
||||
cache_time = 31 * 86400 # = 1 month
|
||||
resp.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format(
|
||||
cache_time)
|
||||
|
||||
file_size = st.st_size
|
||||
|
||||
resp.content_length = file_size
|
||||
with filepath.open('rb') as f:
|
||||
yield from self._sendfile(request, resp, f, file_size)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
GZIP_FILE_SENDER = GzipFileSender()
|
||||
FILE_SENDER = FileSender()
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def staticresource_middleware(app, handler):
|
||||
"""Enhance StaticResourceHandler middleware.
|
||||
|
||||
Adds gzip encoding and fingerprinting matching.
|
||||
"""
|
||||
inst = getattr(handler, '__self__', None)
|
||||
if not isinstance(inst, StaticResource):
|
||||
return handler
|
||||
|
||||
# pylint: disable=protected-access
|
||||
inst._file_sender = GZIP_FILE_SENDER
|
||||
|
||||
@asyncio.coroutine
|
||||
def static_middleware_handler(request):
|
||||
"""Strip out fingerprints from resource names."""
|
||||
fingerprinted = _FINGERPRINT.match(request.match_info['filename'])
|
||||
|
||||
if fingerprinted:
|
||||
request.match_info['filename'] = \
|
||||
'{}.{}'.format(*fingerprinted.groups())
|
||||
|
||||
resp = yield from handler(request)
|
||||
return resp
|
||||
|
||||
return static_middleware_handler
|
||||
25
homeassistant/components/http/util.py
Normal file
25
homeassistant/components/http/util.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""HTTP utilities."""
|
||||
from ipaddress import ip_address
|
||||
|
||||
from .const import (
|
||||
KEY_REAL_IP, KEY_USE_X_FORWARDED_FOR, HTTP_HEADER_X_FORWARDED_FOR)
|
||||
|
||||
|
||||
def get_real_ip(request):
|
||||
"""Get IP address of client."""
|
||||
if KEY_REAL_IP in request:
|
||||
return request[KEY_REAL_IP]
|
||||
|
||||
if (request.app[KEY_USE_X_FORWARDED_FOR] and
|
||||
HTTP_HEADER_X_FORWARDED_FOR in request.headers):
|
||||
request[KEY_REAL_IP] = ip_address(
|
||||
request.headers.get(HTTP_HEADER_X_FORWARDED_FOR).split(',')[0])
|
||||
else:
|
||||
peername = request.transport.get_extra_info('peername')
|
||||
|
||||
if peername:
|
||||
request[KEY_REAL_IP] = ip_address(peername[0])
|
||||
else:
|
||||
request[KEY_REAL_IP] = None
|
||||
|
||||
return request[KEY_REAL_IP]
|
||||
@@ -21,25 +21,24 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_DB_NAME = 'database'
|
||||
CONF_TAGS = 'tags'
|
||||
CONF_DEFAULT_MEASUREMENT = 'default_measurement'
|
||||
|
||||
DEFAULT_DATABASE = 'home_assistant'
|
||||
DEFAULT_HOST = 'localhost'
|
||||
DEFAULT_PORT = 8086
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_VERIFY_SSL = False
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
DOMAIN = 'influxdb'
|
||||
TIMEOUT = 5
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
vol.Optional(CONF_HOST): cv.string,
|
||||
vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
|
||||
vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
|
||||
vol.Optional(CONF_BLACKLIST, default=[]):
|
||||
vol.All(cv.ensure_list, [cv.entity_id]),
|
||||
vol.Optional(CONF_DB_NAME, default=DEFAULT_DATABASE): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_SSL): cv.boolean,
|
||||
vol.Optional(CONF_DEFAULT_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_TAGS, default={}):
|
||||
vol.Schema({cv.string: cv.string}),
|
||||
vol.Optional(CONF_WHITELIST, default=[]):
|
||||
@@ -55,22 +54,34 @@ def setup(hass, config):
|
||||
|
||||
conf = config[DOMAIN]
|
||||
|
||||
host = conf.get(CONF_HOST)
|
||||
port = conf.get(CONF_PORT)
|
||||
database = conf.get(CONF_DB_NAME)
|
||||
username = conf.get(CONF_USERNAME)
|
||||
password = conf.get(CONF_PASSWORD)
|
||||
ssl = conf.get(CONF_SSL)
|
||||
verify_ssl = conf.get(CONF_VERIFY_SSL)
|
||||
kwargs = {
|
||||
'database': conf[CONF_DB_NAME],
|
||||
'verify_ssl': conf[CONF_VERIFY_SSL],
|
||||
'timeout': TIMEOUT
|
||||
}
|
||||
|
||||
if CONF_HOST in conf:
|
||||
kwargs['host'] = conf[CONF_HOST]
|
||||
|
||||
if CONF_PORT in conf:
|
||||
kwargs['port'] = conf[CONF_PORT]
|
||||
|
||||
if CONF_USERNAME in conf:
|
||||
kwargs['username'] = conf[CONF_USERNAME]
|
||||
|
||||
if CONF_PASSWORD in conf:
|
||||
kwargs['password'] = conf[CONF_PASSWORD]
|
||||
|
||||
if CONF_SSL in conf:
|
||||
kwargs['ssl'] = conf[CONF_SSL]
|
||||
|
||||
blacklist = conf.get(CONF_BLACKLIST)
|
||||
whitelist = conf.get(CONF_WHITELIST)
|
||||
tags = conf.get(CONF_TAGS)
|
||||
default_measurement = conf.get(CONF_DEFAULT_MEASUREMENT)
|
||||
|
||||
try:
|
||||
influx = InfluxDBClient(
|
||||
host=host, port=port, username=username, password=password,
|
||||
database=database, ssl=ssl, verify_ssl=verify_ssl,
|
||||
timeout=TIMEOUT)
|
||||
influx = InfluxDBClient(**kwargs)
|
||||
influx.query("select * from /.*/ LIMIT 1;")
|
||||
except exceptions.InfluxDBClientError as exc:
|
||||
_LOGGER.error("Database host is not accessible due to '%s', please "
|
||||
@@ -96,7 +107,10 @@ def setup(hass, config):
|
||||
|
||||
measurement = state.attributes.get('unit_of_measurement')
|
||||
if measurement in (None, ''):
|
||||
measurement = state.entity_id
|
||||
if default_measurement:
|
||||
measurement = default_measurement
|
||||
else:
|
||||
measurement = state.entity_id
|
||||
|
||||
json_body = [
|
||||
{
|
||||
@@ -114,7 +128,12 @@ def setup(hass, config):
|
||||
|
||||
for key, value in state.attributes.items():
|
||||
if key != 'unit_of_measurement':
|
||||
json_body[0]['fields'][key] = value
|
||||
if isinstance(value, (str, float, bool)) or \
|
||||
key.endswith('_id'):
|
||||
json_body[0]['fields'][key] = value
|
||||
elif isinstance(value, int):
|
||||
# Prevent column data errors in influxDB.
|
||||
json_body[0]['fields'][key] = float(value)
|
||||
|
||||
json_body[0]['tags'].update(tags)
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ def async_setup(hass, config):
|
||||
attr = 'async_toggle'
|
||||
|
||||
tasks = [getattr(input_b, attr)() for input_b in target_inputs]
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
if tasks:
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_TURN_OFF, async_handler_service, schema=SERVICE_SCHEMA)
|
||||
|
||||
@@ -113,7 +113,8 @@ def async_setup(hass, config):
|
||||
|
||||
tasks = [input_select.async_select_option(call.data[ATTR_OPTION])
|
||||
for input_select in target_inputs]
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
if tasks:
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SELECT_OPTION, async_select_option_service,
|
||||
@@ -126,7 +127,8 @@ def async_setup(hass, config):
|
||||
|
||||
tasks = [input_select.async_offset_index(1)
|
||||
for input_select in target_inputs]
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
if tasks:
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SELECT_NEXT, async_select_next_service,
|
||||
@@ -139,7 +141,8 @@ def async_setup(hass, config):
|
||||
|
||||
tasks = [input_select.async_offset_index(-1)
|
||||
for input_select in target_inputs]
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
if tasks:
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SELECT_PREVIOUS, async_select_previous_service,
|
||||
|
||||
@@ -105,7 +105,8 @@ def async_setup(hass, config):
|
||||
|
||||
tasks = [input_slider.async_select_value(call.data[ATTR_VALUE])
|
||||
for input_slider in target_inputs]
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
if tasks:
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service,
|
||||
|
||||
@@ -250,11 +250,10 @@ def setup(hass, config):
|
||||
|
||||
discovery.load_platform(hass, "sensor", DOMAIN, {}, config)
|
||||
|
||||
hass.http.register_view(iOSIdentifyDeviceView(hass))
|
||||
hass.http.register_view(iOSIdentifyDeviceView)
|
||||
|
||||
app_config = config.get(DOMAIN, {})
|
||||
hass.http.register_view(iOSPushConfigView(hass,
|
||||
app_config.get(CONF_PUSH, {})))
|
||||
hass.http.register_view(iOSPushConfigView(app_config.get(CONF_PUSH, {})))
|
||||
|
||||
return True
|
||||
|
||||
@@ -266,9 +265,8 @@ class iOSPushConfigView(HomeAssistantView):
|
||||
url = "/api/ios/push"
|
||||
name = "api:ios:push"
|
||||
|
||||
def __init__(self, hass, push_config):
|
||||
def __init__(self, push_config):
|
||||
"""Init the view."""
|
||||
super().__init__(hass)
|
||||
self.push_config = push_config
|
||||
|
||||
@callback
|
||||
@@ -283,10 +281,6 @@ class iOSIdentifyDeviceView(HomeAssistantView):
|
||||
url = "/api/ios/identify"
|
||||
name = "api:ios:identify"
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Init the view."""
|
||||
super().__init__(hass)
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Handle the POST request for device identification."""
|
||||
|
||||
@@ -4,6 +4,7 @@ Provides functionality to interact with lights.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/light/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import csv
|
||||
@@ -64,6 +65,9 @@ ATTR_FLASH = "flash"
|
||||
FLASH_SHORT = "short"
|
||||
FLASH_LONG = "long"
|
||||
|
||||
# List of possible effects
|
||||
ATTR_EFFECT_LIST = "effect_list"
|
||||
|
||||
# Apply an effect to the light, can be EFFECT_COLORLOOP.
|
||||
ATTR_EFFECT = "effect"
|
||||
EFFECT_COLORLOOP = "colorloop"
|
||||
@@ -78,6 +82,8 @@ PROP_TO_ATTR = {
|
||||
'rgb_color': ATTR_RGB_COLOR,
|
||||
'xy_color': ATTR_XY_COLOR,
|
||||
'white_value': ATTR_WHITE_VALUE,
|
||||
'effect_list': ATTR_EFFECT_LIST,
|
||||
'effect': ATTR_EFFECT,
|
||||
'supported_features': ATTR_SUPPORTED_FEATURES,
|
||||
}
|
||||
|
||||
@@ -87,19 +93,20 @@ VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255))
|
||||
|
||||
LIGHT_TURN_ON_SCHEMA = vol.Schema({
|
||||
ATTR_ENTITY_ID: cv.entity_ids,
|
||||
ATTR_PROFILE: str,
|
||||
ATTR_PROFILE: cv.string,
|
||||
ATTR_TRANSITION: VALID_TRANSITION,
|
||||
ATTR_BRIGHTNESS: VALID_BRIGHTNESS,
|
||||
ATTR_COLOR_NAME: str,
|
||||
ATTR_COLOR_NAME: cv.string,
|
||||
ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)),
|
||||
vol.Coerce(tuple)),
|
||||
ATTR_XY_COLOR: vol.All(vol.ExactSequence((cv.small_float, cv.small_float)),
|
||||
vol.Coerce(tuple)),
|
||||
ATTR_COLOR_TEMP: vol.All(int, vol.Range(min=color_util.HASS_COLOR_MIN,
|
||||
max=color_util.HASS_COLOR_MAX)),
|
||||
ATTR_WHITE_VALUE: vol.All(int, vol.Range(min=0, max=255)),
|
||||
ATTR_COLOR_TEMP: vol.All(vol.Coerce(int),
|
||||
vol.Range(min=color_util.HASS_COLOR_MIN,
|
||||
max=color_util.HASS_COLOR_MAX)),
|
||||
ATTR_WHITE_VALUE: vol.All(vol.Coerce(int), vol.Range(min=0, max=255)),
|
||||
ATTR_FLASH: vol.In([FLASH_SHORT, FLASH_LONG]),
|
||||
ATTR_EFFECT: vol.In([EFFECT_COLORLOOP, EFFECT_RANDOM, EFFECT_WHITE]),
|
||||
ATTR_EFFECT: cv.string,
|
||||
})
|
||||
|
||||
LIGHT_TURN_OFF_SCHEMA = vol.Schema({
|
||||
@@ -158,7 +165,7 @@ def async_turn_on(hass, entity_id=None, transition=None, brightness=None,
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
hass.async_add_job(hass.services.async_call, DOMAIN, SERVICE_TURN_ON, data)
|
||||
hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data))
|
||||
|
||||
|
||||
def turn_off(hass, entity_id=None, transition=None):
|
||||
@@ -177,8 +184,8 @@ def async_turn_off(hass, entity_id=None, transition=None):
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
hass.async_add_job(hass.services.async_call, DOMAIN, SERVICE_TURN_OFF,
|
||||
data)
|
||||
hass.async_add_job(hass.services.async_call(
|
||||
DOMAIN, SERVICE_TURN_OFF, data))
|
||||
|
||||
|
||||
def toggle(hass, entity_id=None, transition=None):
|
||||
@@ -193,13 +200,88 @@ def toggle(hass, entity_id=None, transition=None):
|
||||
hass.services.call(DOMAIN, SERVICE_TOGGLE, data)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Expose light control via statemachine and services."""
|
||||
component = EntityComponent(
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS)
|
||||
component.setup(config)
|
||||
yield from component.async_setup(config)
|
||||
|
||||
# Load built-in profiles and custom profiles
|
||||
# load profiles from files
|
||||
profiles = yield from hass.loop.run_in_executor(
|
||||
None, _load_profile_data, hass)
|
||||
|
||||
if profiles is None:
|
||||
return False
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_light_service(service):
|
||||
"""Hande a turn light on or off service call."""
|
||||
# Get the validated data
|
||||
params = service.data.copy()
|
||||
|
||||
# Convert the entity ids to valid light ids
|
||||
target_lights = component.async_extract_from_service(service)
|
||||
params.pop(ATTR_ENTITY_ID, None)
|
||||
|
||||
# Processing extra data for turn light on request.
|
||||
profile = profiles.get(params.pop(ATTR_PROFILE, None))
|
||||
|
||||
if profile:
|
||||
params.setdefault(ATTR_XY_COLOR, profile[:2])
|
||||
params.setdefault(ATTR_BRIGHTNESS, profile[2])
|
||||
|
||||
color_name = params.pop(ATTR_COLOR_NAME, None)
|
||||
|
||||
if color_name is not None:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
|
||||
|
||||
for light in target_lights:
|
||||
if service.service == SERVICE_TURN_ON:
|
||||
yield from light.async_turn_on(**params)
|
||||
elif service.service == SERVICE_TURN_OFF:
|
||||
yield from light.async_turn_off(**params)
|
||||
else:
|
||||
yield from light.async_toggle(**params)
|
||||
|
||||
update_tasks = []
|
||||
|
||||
for light in target_lights:
|
||||
if not light.should_poll:
|
||||
continue
|
||||
|
||||
update_coro = hass.loop.create_task(
|
||||
light.async_update_ha_state(True))
|
||||
if hasattr(light, 'async_update'):
|
||||
update_tasks.append(hass.loop.create_task(update_coro))
|
||||
else:
|
||||
yield from update_coro
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
# Listen for light on and light off service calls.
|
||||
descriptions = yield from hass.loop.run_in_executor(
|
||||
None, load_yaml_config_file, os.path.join(
|
||||
os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_TURN_ON, async_handle_light_service,
|
||||
descriptions.get(SERVICE_TURN_ON), schema=LIGHT_TURN_ON_SCHEMA)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_TURN_OFF, async_handle_light_service,
|
||||
descriptions.get(SERVICE_TURN_OFF), schema=LIGHT_TURN_OFF_SCHEMA)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_TOGGLE, async_handle_light_service,
|
||||
descriptions.get(SERVICE_TOGGLE), schema=LIGHT_TOGGLE_SCHEMA)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _load_profile_data(hass):
|
||||
"""Load built-in profiles and custom profiles."""
|
||||
profile_paths = [os.path.join(os.path.dirname(__file__),
|
||||
LIGHT_PROFILES_FILE),
|
||||
hass.config.path(LIGHT_PROFILES_FILE)]
|
||||
@@ -221,67 +303,8 @@ def setup(hass, config):
|
||||
except vol.MultipleInvalid as ex:
|
||||
_LOGGER.error("Error parsing light profile from %s: %s",
|
||||
profile_path, ex)
|
||||
return False
|
||||
|
||||
def handle_light_service(service):
|
||||
"""Hande a turn light on or off service call."""
|
||||
# Get the validated data
|
||||
params = service.data.copy()
|
||||
|
||||
# Convert the entity ids to valid light ids
|
||||
target_lights = component.extract_from_service(service)
|
||||
params.pop(ATTR_ENTITY_ID, None)
|
||||
|
||||
service_fun = None
|
||||
if service.service == SERVICE_TURN_OFF:
|
||||
service_fun = 'turn_off'
|
||||
elif service.service == SERVICE_TOGGLE:
|
||||
service_fun = 'toggle'
|
||||
|
||||
if service_fun:
|
||||
for light in target_lights:
|
||||
getattr(light, service_fun)(**params)
|
||||
|
||||
for light in target_lights:
|
||||
if light.should_poll:
|
||||
light.update_ha_state(True)
|
||||
return
|
||||
|
||||
# Processing extra data for turn light on request.
|
||||
profile = profiles.get(params.pop(ATTR_PROFILE, None))
|
||||
|
||||
if profile:
|
||||
params.setdefault(ATTR_XY_COLOR, profile[:2])
|
||||
params.setdefault(ATTR_BRIGHTNESS, profile[2])
|
||||
|
||||
color_name = params.pop(ATTR_COLOR_NAME, None)
|
||||
|
||||
if color_name is not None:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
|
||||
|
||||
for light in target_lights:
|
||||
light.turn_on(**params)
|
||||
|
||||
for light in target_lights:
|
||||
if light.should_poll:
|
||||
light.update_ha_state(True)
|
||||
|
||||
# Listen for light on and light off service calls.
|
||||
descriptions = load_yaml_config_file(
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_light_service,
|
||||
descriptions.get(SERVICE_TURN_ON),
|
||||
schema=LIGHT_TURN_ON_SCHEMA)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_light_service,
|
||||
descriptions.get(SERVICE_TURN_OFF),
|
||||
schema=LIGHT_TURN_OFF_SCHEMA)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_TOGGLE, handle_light_service,
|
||||
descriptions.get(SERVICE_TOGGLE),
|
||||
schema=LIGHT_TOGGLE_SCHEMA)
|
||||
|
||||
return True
|
||||
return None
|
||||
return profiles
|
||||
|
||||
|
||||
class Light(ToggleEntity):
|
||||
@@ -314,6 +337,16 @@ class Light(ToggleEntity):
|
||||
"""Return the white value of this light between 0..255."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def effect_list(self):
|
||||
"""Return the list of supported effects."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def effect(self):
|
||||
"""Return the current effect."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return optional state attributes."""
|
||||
|
||||
@@ -7,25 +7,29 @@ https://home-assistant.io/components/demo/
|
||||
import random
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_WHITE_VALUE,
|
||||
ATTR_XY_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR,
|
||||
SUPPORT_WHITE_VALUE, Light)
|
||||
ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT,
|
||||
ATTR_RGB_COLOR, ATTR_WHITE_VALUE, ATTR_XY_COLOR, SUPPORT_BRIGHTNESS,
|
||||
SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_RGB_COLOR, SUPPORT_WHITE_VALUE,
|
||||
Light)
|
||||
|
||||
LIGHT_COLORS = [
|
||||
[237, 224, 33],
|
||||
[255, 63, 111],
|
||||
]
|
||||
|
||||
LIGHT_EFFECT_LIST = ['rainbow', 'none']
|
||||
|
||||
LIGHT_TEMPS = [240, 380]
|
||||
|
||||
SUPPORT_DEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR |
|
||||
SUPPORT_WHITE_VALUE)
|
||||
SUPPORT_DEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT |
|
||||
SUPPORT_RGB_COLOR | SUPPORT_WHITE_VALUE)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
"""Setup the demo light platform."""
|
||||
add_devices_callback([
|
||||
DemoLight("Bed Light", False),
|
||||
DemoLight("Bed Light", False, effect_list=LIGHT_EFFECT_LIST,
|
||||
effect=LIGHT_EFFECT_LIST[0]),
|
||||
DemoLight("Ceiling Lights", True, LIGHT_COLORS[0], LIGHT_TEMPS[1]),
|
||||
DemoLight("Kitchen Lights", True, LIGHT_COLORS[1], LIGHT_TEMPS[0])
|
||||
])
|
||||
@@ -36,7 +40,7 @@ class DemoLight(Light):
|
||||
|
||||
def __init__(
|
||||
self, name, state, rgb=None, ct=None, brightness=180,
|
||||
xy_color=(.5, .5), white=200):
|
||||
xy_color=(.5, .5), white=200, effect_list=None, effect=None):
|
||||
"""Initialize the light."""
|
||||
self._name = name
|
||||
self._state = state
|
||||
@@ -45,6 +49,8 @@ class DemoLight(Light):
|
||||
self._brightness = brightness
|
||||
self._xy_color = xy_color
|
||||
self._white = white
|
||||
self._effect_list = effect_list
|
||||
self._effect = effect
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -81,6 +87,16 @@ class DemoLight(Light):
|
||||
"""Return the white value of this light between 0..255."""
|
||||
return self._white
|
||||
|
||||
@property
|
||||
def effect_list(self):
|
||||
"""Return the list of supported effects."""
|
||||
return self._effect_list
|
||||
|
||||
@property
|
||||
def effect(self):
|
||||
"""Return the current effect."""
|
||||
return self._effect
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if light is on."""
|
||||
@@ -110,9 +126,12 @@ class DemoLight(Light):
|
||||
if ATTR_WHITE_VALUE in kwargs:
|
||||
self._white = kwargs[ATTR_WHITE_VALUE]
|
||||
|
||||
self.update_ha_state()
|
||||
if ATTR_EFFECT in kwargs:
|
||||
self._effect = kwargs[ATTR_EFFECT]
|
||||
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn the light off."""
|
||||
self._state = False
|
||||
self.update_ha_state()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user