mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Merge branch 'dev' of https://github.com/home-assistant/home-assistant into dev
# Conflicts: # homeassistant/components/sensor/smappee.py
This commit is contained in:
commit
2da6d3c223
99
.coveragerc
99
.coveragerc
@ -4,6 +4,8 @@ source = homeassistant
|
||||
omit =
|
||||
homeassistant/__main__.py
|
||||
homeassistant/scripts/*.py
|
||||
homeassistant/util/async.py
|
||||
homeassistant/monkey_patch.py
|
||||
homeassistant/helpers/typing.py
|
||||
homeassistant/helpers/signal.py
|
||||
|
||||
@ -29,7 +31,7 @@ omit =
|
||||
homeassistant/components/arduino.py
|
||||
homeassistant/components/*/arduino.py
|
||||
|
||||
homeassistant/components/bmw_connected_drive.py
|
||||
homeassistant/components/bmw_connected_drive/*.py
|
||||
homeassistant/components/*/bmw_connected_drive.py
|
||||
|
||||
homeassistant/components/android_ip_webcam.py
|
||||
@ -94,6 +96,12 @@ omit =
|
||||
homeassistant/components/envisalink.py
|
||||
homeassistant/components/*/envisalink.py
|
||||
|
||||
homeassistant/components/fritzbox.py
|
||||
homeassistant/components/*/fritzbox.py
|
||||
|
||||
homeassistant/components/eufy.py
|
||||
homeassistant/components/*/eufy.py
|
||||
|
||||
homeassistant/components/gc100.py
|
||||
homeassistant/components/*/gc100.py
|
||||
|
||||
@ -106,16 +114,25 @@ omit =
|
||||
homeassistant/components/hive.py
|
||||
homeassistant/components/*/hive.py
|
||||
|
||||
homeassistant/components/homekit_controller/__init__.py
|
||||
homeassistant/components/*/homekit_controller.py
|
||||
|
||||
homeassistant/components/homematic/__init__.py
|
||||
homeassistant/components/*/homematic.py
|
||||
|
||||
homeassistant/components/homematicip_cloud.py
|
||||
homeassistant/components/*/homematicip_cloud.py
|
||||
|
||||
homeassistant/components/hydrawise.py
|
||||
homeassistant/components/*/hydrawise.py
|
||||
|
||||
homeassistant/components/ihc/*
|
||||
homeassistant/components/*/ihc.py
|
||||
|
||||
homeassistant/components/insteon_local.py
|
||||
homeassistant/components/*/insteon_local.py
|
||||
|
||||
homeassistant/components/insteon_plm.py
|
||||
homeassistant/components/insteon_plm/*
|
||||
homeassistant/components/*/insteon_plm.py
|
||||
|
||||
homeassistant/components/ios.py
|
||||
@ -139,6 +156,9 @@ omit =
|
||||
homeassistant/components/knx.py
|
||||
homeassistant/components/*/knx.py
|
||||
|
||||
homeassistant/components/konnected.py
|
||||
homeassistant/components/*/konnected.py
|
||||
|
||||
homeassistant/components/lametric.py
|
||||
homeassistant/components/*/lametric.py
|
||||
|
||||
@ -154,12 +174,12 @@ omit =
|
||||
homeassistant/components/mailgun.py
|
||||
homeassistant/components/*/mailgun.py
|
||||
|
||||
homeassistant/components/matrix.py
|
||||
homeassistant/components/*/matrix.py
|
||||
|
||||
homeassistant/components/maxcube.py
|
||||
homeassistant/components/*/maxcube.py
|
||||
|
||||
homeassistant/components/mercedesme.py
|
||||
homeassistant/components/*/mercedesme.py
|
||||
|
||||
homeassistant/components/mochad.py
|
||||
homeassistant/components/*/mochad.py
|
||||
|
||||
@ -190,8 +210,8 @@ omit =
|
||||
homeassistant/components/pilight.py
|
||||
homeassistant/components/*/pilight.py
|
||||
|
||||
homeassistant/components/qwikswitch.py
|
||||
homeassistant/components/*/qwikswitch.py
|
||||
homeassistant/components/switch/qwikswitch.py
|
||||
homeassistant/components/light/qwikswitch.py
|
||||
|
||||
homeassistant/components/rachio.py
|
||||
homeassistant/components/*/rachio.py
|
||||
@ -199,6 +219,9 @@ omit =
|
||||
homeassistant/components/raincloud.py
|
||||
homeassistant/components/*/raincloud.py
|
||||
|
||||
homeassistant/components/rainmachine/*
|
||||
homeassistant/components/*/rainmachine.py
|
||||
|
||||
homeassistant/components/raspihats.py
|
||||
homeassistant/components/*/raspihats.py
|
||||
|
||||
@ -211,6 +234,9 @@ omit =
|
||||
homeassistant/components/rpi_pfio.py
|
||||
homeassistant/components/*/rpi_pfio.py
|
||||
|
||||
homeassistant/components/sabnzbd.py
|
||||
homeassistant/components/*/sabnzbd.py
|
||||
|
||||
homeassistant/components/satel_integra.py
|
||||
homeassistant/components/*/satel_integra.py
|
||||
|
||||
@ -286,11 +312,9 @@ omit =
|
||||
homeassistant/components/*/wink.py
|
||||
|
||||
homeassistant/components/xiaomi_aqara.py
|
||||
homeassistant/components/binary_sensor/xiaomi_aqara.py
|
||||
homeassistant/components/cover/xiaomi_aqara.py
|
||||
homeassistant/components/light/xiaomi_aqara.py
|
||||
homeassistant/components/sensor/xiaomi_aqara.py
|
||||
homeassistant/components/switch/xiaomi_aqara.py
|
||||
homeassistant/components/*/xiaomi_aqara.py
|
||||
|
||||
homeassistant/components/*/xiaomi_miio.py
|
||||
|
||||
homeassistant/components/zabbix.py
|
||||
homeassistant/components/*/zabbix.py
|
||||
@ -309,6 +333,7 @@ omit =
|
||||
homeassistant/components/alarm_control_panel/canary.py
|
||||
homeassistant/components/alarm_control_panel/concord232.py
|
||||
homeassistant/components/alarm_control_panel/ialarm.py
|
||||
homeassistant/components/alarm_control_panel/ifttt.py
|
||||
homeassistant/components/alarm_control_panel/manual_mqtt.py
|
||||
homeassistant/components/alarm_control_panel/nx584.py
|
||||
homeassistant/components/alarm_control_panel/simplisafe.py
|
||||
@ -328,10 +353,12 @@ omit =
|
||||
homeassistant/components/calendar/todoist.py
|
||||
homeassistant/components/camera/bloomsky.py
|
||||
homeassistant/components/camera/canary.py
|
||||
homeassistant/components/camera/familyhub.py
|
||||
homeassistant/components/camera/ffmpeg.py
|
||||
homeassistant/components/camera/foscam.py
|
||||
homeassistant/components/camera/mjpeg.py
|
||||
homeassistant/components/camera/onvif.py
|
||||
homeassistant/components/camera/proxy.py
|
||||
homeassistant/components/camera/ring.py
|
||||
homeassistant/components/camera/rpi_camera.py
|
||||
homeassistant/components/camera/synology.py
|
||||
@ -352,11 +379,13 @@ omit =
|
||||
homeassistant/components/climate/touchline.py
|
||||
homeassistant/components/climate/venstar.py
|
||||
homeassistant/components/cover/garadget.py
|
||||
homeassistant/components/cover/gogogate2.py
|
||||
homeassistant/components/cover/homematic.py
|
||||
homeassistant/components/cover/knx.py
|
||||
homeassistant/components/cover/myq.py
|
||||
homeassistant/components/cover/opengarage.py
|
||||
homeassistant/components/cover/rpi_gpio.py
|
||||
homeassistant/components/cover/ryobi_gdo.py
|
||||
homeassistant/components/cover/scsgate.py
|
||||
homeassistant/components/device_tracker/actiontec.py
|
||||
homeassistant/components/device_tracker/aruba.py
|
||||
@ -369,6 +398,7 @@ omit =
|
||||
homeassistant/components/device_tracker/cisco_ios.py
|
||||
homeassistant/components/device_tracker/ddwrt.py
|
||||
homeassistant/components/device_tracker/fritz.py
|
||||
homeassistant/components/device_tracker/google_maps.py
|
||||
homeassistant/components/device_tracker/gpslogger.py
|
||||
homeassistant/components/device_tracker/hitron_coda.py
|
||||
homeassistant/components/device_tracker/huawei_router.py
|
||||
@ -395,30 +425,31 @@ omit =
|
||||
homeassistant/components/emoncms_history.py
|
||||
homeassistant/components/emulated_hue/upnp.py
|
||||
homeassistant/components/fan/mqtt.py
|
||||
homeassistant/components/fan/xiaomi_miio.py
|
||||
homeassistant/components/feedreader.py
|
||||
homeassistant/components/folder_watcher.py
|
||||
homeassistant/components/foursquare.py
|
||||
homeassistant/components/goalfeed.py
|
||||
homeassistant/components/ifttt.py
|
||||
homeassistant/components/image_processing/dlib_face_detect.py
|
||||
homeassistant/components/image_processing/dlib_face_identify.py
|
||||
homeassistant/components/image_processing/seven_segments.py
|
||||
homeassistant/components/keyboard.py
|
||||
homeassistant/components/keyboard_remote.py
|
||||
homeassistant/components/keyboard.py
|
||||
homeassistant/components/light/avion.py
|
||||
homeassistant/components/light/blinksticklight.py
|
||||
homeassistant/components/light/blinkt.py
|
||||
homeassistant/components/light/decora.py
|
||||
homeassistant/components/light/decora_wifi.py
|
||||
homeassistant/components/light/decora.py
|
||||
homeassistant/components/light/flux_led.py
|
||||
homeassistant/components/light/greenwave.py
|
||||
homeassistant/components/light/hue.py
|
||||
homeassistant/components/light/hyperion.py
|
||||
homeassistant/components/light/iglo.py
|
||||
homeassistant/components/light/lifx.py
|
||||
homeassistant/components/light/lifx_legacy.py
|
||||
homeassistant/components/light/lifx.py
|
||||
homeassistant/components/light/limitlessled.py
|
||||
homeassistant/components/light/lw12wifi.py
|
||||
homeassistant/components/light/mystrom.py
|
||||
homeassistant/components/light/nanoleaf_aurora.py
|
||||
homeassistant/components/light/osramlightify.py
|
||||
homeassistant/components/light/piglow.py
|
||||
homeassistant/components/light/rpi_gpio_pwm.py
|
||||
@ -427,7 +458,6 @@ omit =
|
||||
homeassistant/components/light/tplink.py
|
||||
homeassistant/components/light/tradfri.py
|
||||
homeassistant/components/light/x10.py
|
||||
homeassistant/components/light/xiaomi_miio.py
|
||||
homeassistant/components/light/yeelight.py
|
||||
homeassistant/components/light/yeelightsunflower.py
|
||||
homeassistant/components/light/zengge.py
|
||||
@ -436,12 +466,14 @@ omit =
|
||||
homeassistant/components/lock/nello.py
|
||||
homeassistant/components/lock/nuki.py
|
||||
homeassistant/components/lock/sesame.py
|
||||
homeassistant/components/map.py
|
||||
homeassistant/components/media_extractor.py
|
||||
homeassistant/components/media_player/anthemav.py
|
||||
homeassistant/components/media_player/aquostv.py
|
||||
homeassistant/components/media_player/bluesound.py
|
||||
homeassistant/components/media_player/braviatv.py
|
||||
homeassistant/components/media_player/cast.py
|
||||
homeassistant/components/media_player/channels.py
|
||||
homeassistant/components/media_player/clementine.py
|
||||
homeassistant/components/media_player/cmus.py
|
||||
homeassistant/components/media_player/denon.py
|
||||
@ -482,8 +514,8 @@ omit =
|
||||
homeassistant/components/media_player/vlc.py
|
||||
homeassistant/components/media_player/volumio.py
|
||||
homeassistant/components/media_player/xiaomi_tv.py
|
||||
homeassistant/components/media_player/yamaha.py
|
||||
homeassistant/components/media_player/yamaha_musiccast.py
|
||||
homeassistant/components/media_player/yamaha.py
|
||||
homeassistant/components/media_player/ziggo_mediabox_xl.py
|
||||
homeassistant/components/mycroft.py
|
||||
homeassistant/components/notify/aws_lambda.py
|
||||
@ -494,6 +526,7 @@ omit =
|
||||
homeassistant/components/notify/clicksend.py
|
||||
homeassistant/components/notify/clicksend_tts.py
|
||||
homeassistant/components/notify/discord.py
|
||||
homeassistant/components/notify/flock.py
|
||||
homeassistant/components/notify/free_mobile.py
|
||||
homeassistant/components/notify/gntp.py
|
||||
homeassistant/components/notify/group.py
|
||||
@ -502,11 +535,10 @@ omit =
|
||||
homeassistant/components/notify/kodi.py
|
||||
homeassistant/components/notify/lannouncer.py
|
||||
homeassistant/components/notify/llamalab_automate.py
|
||||
homeassistant/components/notify/matrix.py
|
||||
homeassistant/components/notify/mastodon.py
|
||||
homeassistant/components/notify/message_bird.py
|
||||
homeassistant/components/notify/mycroft.py
|
||||
homeassistant/components/notify/nfandroidtv.py
|
||||
homeassistant/components/notify/nma.py
|
||||
homeassistant/components/notify/prowl.py
|
||||
homeassistant/components/notify/pushbullet.py
|
||||
homeassistant/components/notify/pushetta.py
|
||||
@ -518,6 +550,7 @@ omit =
|
||||
homeassistant/components/notify/simplepush.py
|
||||
homeassistant/components/notify/slack.py
|
||||
homeassistant/components/notify/smtp.py
|
||||
homeassistant/components/notify/stride.py
|
||||
homeassistant/components/notify/synology_chat.py
|
||||
homeassistant/components/notify/syslog.py
|
||||
homeassistant/components/notify/telegram.py
|
||||
@ -531,7 +564,6 @@ omit =
|
||||
homeassistant/components/remember_the_milk/__init__.py
|
||||
homeassistant/components/remote/harmony.py
|
||||
homeassistant/components/remote/itach.py
|
||||
homeassistant/components/remote/xiaomi_miio.py
|
||||
homeassistant/components/scene/hunterdouglas_powerview.py
|
||||
homeassistant/components/scene/lifx_cloud.py
|
||||
homeassistant/components/sensor/airvisual.py
|
||||
@ -554,13 +586,13 @@ omit =
|
||||
homeassistant/components/sensor/crimereports.py
|
||||
homeassistant/components/sensor/cups.py
|
||||
homeassistant/components/sensor/currencylayer.py
|
||||
homeassistant/components/sensor/darksky.py
|
||||
homeassistant/components/sensor/deluge.py
|
||||
homeassistant/components/sensor/deutsche_bahn.py
|
||||
homeassistant/components/sensor/dht.py
|
||||
homeassistant/components/sensor/discogs.py
|
||||
homeassistant/components/sensor/dnsip.py
|
||||
homeassistant/components/sensor/dovado.py
|
||||
homeassistant/components/sensor/domain_expiry.py
|
||||
homeassistant/components/sensor/dte_energy_bridge.py
|
||||
homeassistant/components/sensor/dublin_bus_transport.py
|
||||
homeassistant/components/sensor/dwd_weather_warnings.py
|
||||
@ -573,9 +605,11 @@ omit =
|
||||
homeassistant/components/sensor/fastdotcom.py
|
||||
homeassistant/components/sensor/fedex.py
|
||||
homeassistant/components/sensor/filesize.py
|
||||
homeassistant/components/sensor/fints.py
|
||||
homeassistant/components/sensor/fitbit.py
|
||||
homeassistant/components/sensor/fixer.py
|
||||
homeassistant/components/sensor/folder.py
|
||||
homeassistant/components/sensor/foobot.py
|
||||
homeassistant/components/sensor/fritzbox_callmonitor.py
|
||||
homeassistant/components/sensor/fritzbox_netmonitor.py
|
||||
homeassistant/components/sensor/gearbest.py
|
||||
@ -588,9 +622,10 @@ omit =
|
||||
homeassistant/components/sensor/haveibeenpwned.py
|
||||
homeassistant/components/sensor/hp_ilo.py
|
||||
homeassistant/components/sensor/htu21d.py
|
||||
homeassistant/components/sensor/imap.py
|
||||
homeassistant/components/sensor/imap_email_content.py
|
||||
homeassistant/components/sensor/imap.py
|
||||
homeassistant/components/sensor/influxdb.py
|
||||
homeassistant/components/sensor/iperf3.py
|
||||
homeassistant/components/sensor/irish_rail_transport.py
|
||||
homeassistant/components/sensor/kwb.py
|
||||
homeassistant/components/sensor/lacrosse.py
|
||||
@ -601,6 +636,7 @@ omit =
|
||||
homeassistant/components/sensor/lyft.py
|
||||
homeassistant/components/sensor/metoffice.py
|
||||
homeassistant/components/sensor/miflora.py
|
||||
homeassistant/components/sensor/mitemp_bt.py
|
||||
homeassistant/components/sensor/modem_callerid.py
|
||||
homeassistant/components/sensor/mopar.py
|
||||
homeassistant/components/sensor/mqtt_room.py
|
||||
@ -621,6 +657,7 @@ omit =
|
||||
homeassistant/components/sensor/plex.py
|
||||
homeassistant/components/sensor/pocketcasts.py
|
||||
homeassistant/components/sensor/pollen.py
|
||||
homeassistant/components/sensor/postnl.py
|
||||
homeassistant/components/sensor/pushbullet.py
|
||||
homeassistant/components/sensor/pvoutput.py
|
||||
homeassistant/components/sensor/pyload.py
|
||||
@ -628,18 +665,20 @@ omit =
|
||||
homeassistant/components/sensor/radarr.py
|
||||
homeassistant/components/sensor/rainbird.py
|
||||
homeassistant/components/sensor/ripple.py
|
||||
homeassistant/components/sensor/sabnzbd.py
|
||||
homeassistant/components/sensor/scrape.py
|
||||
homeassistant/components/sensor/sense.py
|
||||
homeassistant/components/sensor/sensehat.py
|
||||
homeassistant/components/sensor/serial.py
|
||||
homeassistant/components/sensor/serial_pm.py
|
||||
homeassistant/components/sensor/serial.py
|
||||
homeassistant/components/sensor/sht31.py
|
||||
homeassistant/components/sensor/shodan.py
|
||||
homeassistant/components/sensor/sigfox.py
|
||||
homeassistant/components/sensor/simulated.py
|
||||
homeassistant/components/sensor/skybeacon.py
|
||||
homeassistant/components/sensor/sma.py
|
||||
homeassistant/components/sensor/snmp.py
|
||||
homeassistant/components/sensor/sochain.py
|
||||
homeassistant/components/sensor/socialblade.py
|
||||
homeassistant/components/sensor/sonarr.py
|
||||
homeassistant/components/sensor/speedtest.py
|
||||
homeassistant/components/sensor/spotcrime.py
|
||||
@ -647,6 +686,7 @@ omit =
|
||||
homeassistant/components/sensor/supervisord.py
|
||||
homeassistant/components/sensor/swiss_hydrological_data.py
|
||||
homeassistant/components/sensor/swiss_public_transport.py
|
||||
homeassistant/components/sensor/syncthru.py
|
||||
homeassistant/components/sensor/synologydsm.py
|
||||
homeassistant/components/sensor/systemmonitor.py
|
||||
homeassistant/components/sensor/sytadin.py
|
||||
@ -656,15 +696,18 @@ omit =
|
||||
homeassistant/components/sensor/tibber.py
|
||||
homeassistant/components/sensor/time_date.py
|
||||
homeassistant/components/sensor/torque.py
|
||||
homeassistant/components/sensor/trafikverket_weatherstation.py
|
||||
homeassistant/components/sensor/transmission.py
|
||||
homeassistant/components/sensor/travisci.py
|
||||
homeassistant/components/sensor/twitch.py
|
||||
homeassistant/components/sensor/uber.py
|
||||
homeassistant/components/sensor/upnp.py
|
||||
homeassistant/components/sensor/ups.py
|
||||
homeassistant/components/sensor/uscis.py
|
||||
homeassistant/components/sensor/vasttrafik.py
|
||||
homeassistant/components/sensor/viaggiatreno.py
|
||||
homeassistant/components/sensor/waqi.py
|
||||
homeassistant/components/sensor/waze_travel_time.py
|
||||
homeassistant/components/sensor/whois.py
|
||||
homeassistant/components/sensor/worldtidesinfo.py
|
||||
homeassistant/components/sensor/worxlandroid.py
|
||||
@ -690,14 +733,13 @@ omit =
|
||||
homeassistant/components/switch/orvibo.py
|
||||
homeassistant/components/switch/pulseaudio_loopback.py
|
||||
homeassistant/components/switch/rainbird.py
|
||||
homeassistant/components/switch/rainmachine.py
|
||||
homeassistant/components/switch/rest.py
|
||||
homeassistant/components/switch/rpi_rf.py
|
||||
homeassistant/components/switch/snmp.py
|
||||
homeassistant/components/switch/telnet.py
|
||||
homeassistant/components/switch/tplink.py
|
||||
homeassistant/components/switch/transmission.py
|
||||
homeassistant/components/switch/xiaomi_miio.py
|
||||
homeassistant/components/switch/vesync.py
|
||||
homeassistant/components/telegram_bot/*
|
||||
homeassistant/components/thingspeak.py
|
||||
homeassistant/components/tts/amazon_polly.py
|
||||
@ -706,7 +748,6 @@ omit =
|
||||
homeassistant/components/tts/picotts.py
|
||||
homeassistant/components/vacuum/mqtt.py
|
||||
homeassistant/components/vacuum/roomba.py
|
||||
homeassistant/components/vacuum/xiaomi_miio.py
|
||||
homeassistant/components/weather/bom.py
|
||||
homeassistant/components/weather/buienradar.py
|
||||
homeassistant/components/weather/darksky.py
|
||||
|
38
.github/ISSUE_TEMPLATE.md
vendored
38
.github/ISSUE_TEMPLATE.md
vendored
@ -1,35 +1,45 @@
|
||||
Make sure you are running the latest version of Home Assistant before reporting an issue.
|
||||
<!-- READ THIS FIRST:
|
||||
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
|
||||
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/home-assistant/releases
|
||||
- Do not report issues for components if you are using custom components: files in <config-dir>/custom_components
|
||||
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
|
||||
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
|
||||
-->
|
||||
|
||||
You should only file an issue if you found a bug. Feature and enhancement requests should go in [the Feature Requests section](https://community.home-assistant.io/c/feature-requests) of our community forum:
|
||||
|
||||
**Home Assistant release (`hass --version`):**
|
||||
**Home Assistant release with the issue:**
|
||||
<!--
|
||||
- Frontend -> Developer tools -> Info
|
||||
- Or use this command: hass --version
|
||||
-->
|
||||
|
||||
|
||||
**Python release (`python3 --version`):**
|
||||
**Last working Home Assistant release (if known):**
|
||||
|
||||
|
||||
**Operating environment (Hass.io/Docker/Windows/etc.):**
|
||||
<!--
|
||||
Please provide details about your environment.
|
||||
-->
|
||||
|
||||
**Component/platform:**
|
||||
<!--
|
||||
Please add the link to the documentation at https://www.home-assistant.io/components/ of the component/platform in question.
|
||||
-->
|
||||
|
||||
|
||||
**Description of problem:**
|
||||
|
||||
|
||||
**Expected:**
|
||||
|
||||
|
||||
**Problem-relevant `configuration.yaml` entries and steps to reproduce:**
|
||||
**Problem-relevant `configuration.yaml` entries and (fill out even if it seems unimportant):**
|
||||
```yaml
|
||||
|
||||
```
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Traceback (if applicable):**
|
||||
```bash
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
**Additional info:**
|
||||
**Additional information:**
|
||||
|
||||
|
50
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
Normal file
50
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
|
||||
---
|
||||
|
||||
<!-- READ THIS FIRST:
|
||||
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
|
||||
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/home-assistant/releases
|
||||
- Do not report issues for components if you are using custom components: files in <config-dir>/custom_components
|
||||
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
|
||||
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
|
||||
-->
|
||||
|
||||
**Home Assistant release with the issue:**
|
||||
<!--
|
||||
- Frontend -> Developer tools -> Info
|
||||
- Or use this command: hass --version
|
||||
-->
|
||||
|
||||
|
||||
**Last working Home Assistant release (if known):**
|
||||
|
||||
|
||||
**Operating environment (Hass.io/Docker/Windows/etc.):**
|
||||
<!--
|
||||
Please provide details about your environment.
|
||||
-->
|
||||
|
||||
**Component/platform:**
|
||||
<!--
|
||||
Please add the link to the documentation at https://www.home-assistant.io/components/ of the component/platform in question.
|
||||
-->
|
||||
|
||||
|
||||
**Description of problem:**
|
||||
|
||||
|
||||
|
||||
**Problem-relevant `configuration.yaml` entries and (fill out even if it seems unimportant):**
|
||||
```yaml
|
||||
|
||||
```
|
||||
|
||||
**Traceback (if applicable):**
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
**Additional information:**
|
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -20,7 +20,7 @@ If user exposed functionality or configuration variables are added/changed:
|
||||
If the code communicates with devices, web services, or third-party tools:
|
||||
- [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]).
|
||||
- [ ] New dependencies are only imported inside functions that use them ([example][ex-import]).
|
||||
- [ ] New dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`.
|
||||
- [ ] New or updated dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`.
|
||||
- [ ] New files were added to `.coveragerc`.
|
||||
|
||||
If the code does not interact with devices:
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,6 +21,7 @@ Icon
|
||||
*.iml
|
||||
|
||||
# pytest
|
||||
.pytest_cache
|
||||
.cache
|
||||
|
||||
# GITHUB Proposed Python stuff:
|
||||
|
0
.gitmodules
vendored
0
.gitmodules
vendored
@ -10,8 +10,8 @@ matrix:
|
||||
env: TOXENV=lint
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=pylint
|
||||
# - python: "3.5"
|
||||
# env: TOXENV=typing
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=typing
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=py35
|
||||
- python: "3.6"
|
||||
@ -31,7 +31,7 @@ script: travis_wait 30 tox --develop
|
||||
services:
|
||||
- docker
|
||||
before_deploy:
|
||||
- docker pull lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7
|
||||
- docker pull lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21
|
||||
deploy:
|
||||
skip_cleanup: true
|
||||
provider: script
|
||||
|
34
CODEOWNERS
34
CODEOWNERS
@ -29,9 +29,6 @@ homeassistant/components/weblink.py @home-assistant/core
|
||||
homeassistant/components/websocket_api.py @home-assistant/core
|
||||
homeassistant/components/zone.py @home-assistant/core
|
||||
|
||||
# To monitor non-pypi additions
|
||||
requirements_all.txt @andrey-git
|
||||
|
||||
# HomeAssistant developer Teams
|
||||
Dockerfile @home-assistant/docker
|
||||
virtualization/Docker/* @home-assistant/docker
|
||||
@ -43,20 +40,25 @@ homeassistant/components/hassio.py @home-assistant/hassio
|
||||
|
||||
# Individual components
|
||||
homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt
|
||||
homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell
|
||||
homeassistant/components/binary_sensor/hikvision.py @mezz64
|
||||
homeassistant/components/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/camera/yi.py @bachya
|
||||
homeassistant/components/climate/ephember.py @ttroy50
|
||||
homeassistant/components/climate/eq3btsmart.py @rytilahti
|
||||
homeassistant/components/climate/sensibo.py @andrey-git
|
||||
homeassistant/components/cover/group.py @cdce8p
|
||||
homeassistant/components/cover/template.py @PhracturedBlue
|
||||
homeassistant/components/device_tracker/automatic.py @armills
|
||||
homeassistant/components/device_tracker/tile.py @bachya
|
||||
homeassistant/components/history_graph.py @andrey-git
|
||||
homeassistant/components/light/tplink.py @rytilahti
|
||||
homeassistant/components/light/yeelight.py @rytilahti
|
||||
homeassistant/components/lock/nello.py @pschmitt
|
||||
homeassistant/components/lock/nuki.py @pschmitt
|
||||
homeassistant/components/media_player/emby.py @mezz64
|
||||
homeassistant/components/media_player/kodi.py @armills
|
||||
homeassistant/components/media_player/liveboxplaytv.py @pschmitt
|
||||
homeassistant/components/media_player/mediaroom.py @dgomes
|
||||
homeassistant/components/media_player/monoprice.py @etsinko
|
||||
homeassistant/components/media_player/sonos.py @amelchio
|
||||
@ -64,32 +66,42 @@ homeassistant/components/media_player/xiaomi_tv.py @fattdev
|
||||
homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth
|
||||
homeassistant/components/plant.py @ChristianKuehnel
|
||||
homeassistant/components/sensor/airvisual.py @bachya
|
||||
homeassistant/components/sensor/filter.py @dgomes
|
||||
homeassistant/components/sensor/gearbest.py @HerrHofrat
|
||||
homeassistant/components/sensor/irish_rail_transport.py @ttroy50
|
||||
homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel
|
||||
homeassistant/components/sensor/pollen.py @bachya
|
||||
homeassistant/components/sensor/sytadin.py @gautric
|
||||
homeassistant/components/sensor/qnap.py @colinodell
|
||||
homeassistant/components/sensor/sma.py @kellerza
|
||||
homeassistant/components/sensor/sql.py @dgomes
|
||||
homeassistant/components/sensor/sytadin.py @gautric
|
||||
homeassistant/components/sensor/tibber.py @danielhiversen
|
||||
homeassistant/components/sensor/upnp.py @dgomes
|
||||
homeassistant/components/sensor/waqi.py @andrey-git
|
||||
homeassistant/components/switch/rainmachine.py @bachya
|
||||
homeassistant/components/switch/tplink.py @rytilahti
|
||||
homeassistant/components/vacuum/roomba.py @pschmitt
|
||||
homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi
|
||||
|
||||
homeassistant/components/*/axis.py @kane610
|
||||
homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/*/broadlink.py @danielhiversen
|
||||
homeassistant/components/*/deconz.py @kane610
|
||||
homeassistant/components/eight_sleep.py @mezz64
|
||||
homeassistant/components/*/eight_sleep.py @mezz64
|
||||
homeassistant/components/hive.py @Rendili @KJonline
|
||||
homeassistant/components/*/hive.py @Rendili @KJonline
|
||||
homeassistant/components/homekit/* @cdce8p
|
||||
homeassistant/components/*/deconz.py @kane610
|
||||
homeassistant/components/*/rfxtrx.py @danielhiversen
|
||||
homeassistant/components/velux.py @Julius2342
|
||||
homeassistant/components/*/velux.py @Julius2342
|
||||
homeassistant/components/knx.py @Julius2342
|
||||
homeassistant/components/*/knx.py @Julius2342
|
||||
homeassistant/components/konnected.py @heythisisnate
|
||||
homeassistant/components/*/konnected.py @heythisisnate
|
||||
homeassistant/components/matrix.py @tinloaf
|
||||
homeassistant/components/*/matrix.py @tinloaf
|
||||
homeassistant/components/qwikswitch.py @kellerza
|
||||
homeassistant/components/*/qwikswitch.py @kellerza
|
||||
homeassistant/components/rainmachine/* @bachya
|
||||
homeassistant/components/*/rainmachine.py @bachya
|
||||
homeassistant/components/*/rfxtrx.py @danielhiversen
|
||||
homeassistant/components/tahoma.py @philklei
|
||||
homeassistant/components/*/tahoma.py @philklei
|
||||
homeassistant/components/tesla.py @zabuldon
|
||||
@ -97,5 +109,9 @@ homeassistant/components/*/tesla.py @zabuldon
|
||||
homeassistant/components/tellduslive.py @molobrakos @fredrike
|
||||
homeassistant/components/*/tellduslive.py @molobrakos @fredrike
|
||||
homeassistant/components/*/tradfri.py @ggravlingen
|
||||
homeassistant/components/velux.py @Julius2342
|
||||
homeassistant/components/*/velux.py @Julius2342
|
||||
homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi
|
||||
homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi
|
||||
|
||||
homeassistant/scripts/check_config.py @kellerza
|
||||
|
@ -4,7 +4,7 @@ Everybody is invited and welcome to contribute to Home Assistant. There is a lot
|
||||
|
||||
The process is straight-forward.
|
||||
|
||||
- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/devel/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0)
|
||||
- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) 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.
|
||||
|
@ -12,6 +12,7 @@ LABEL maintainer="Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>"
|
||||
#ENV INSTALL_LIBCEC no
|
||||
#ENV INSTALL_PHANTOMJS no
|
||||
#ENV INSTALL_SSOCR no
|
||||
#ENV INSTALL_IPERF3 no
|
||||
|
||||
VOLUME /config
|
||||
|
||||
|
@ -1,606 +0,0 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Home Assistant
|
||||
description: Home Assistant REST API
|
||||
version: "1.0.1"
|
||||
# the domain of the service
|
||||
host: localhost:8123
|
||||
|
||||
# array of all schemes that your API supports
|
||||
schemes:
|
||||
- http
|
||||
- https
|
||||
|
||||
securityDefinitions:
|
||||
#api_key:
|
||||
# type: apiKey
|
||||
# description: API password
|
||||
# name: api_password
|
||||
# in: query
|
||||
|
||||
api_key:
|
||||
type: apiKey
|
||||
description: API password
|
||||
name: x-ha-access
|
||||
in: header
|
||||
|
||||
# will be prefixed to all paths
|
||||
basePath: /api
|
||||
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
summary: API alive message
|
||||
description: Returns message if API is up and running.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: API is up and running
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/config:
|
||||
get:
|
||||
summary: API alive message
|
||||
description: Returns the current configuration as JSON.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: Current configuration
|
||||
schema:
|
||||
$ref: '#/definitions/ApiConfig'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/discovery_info:
|
||||
get:
|
||||
summary: Basic information about Home Assistant instance
|
||||
tags:
|
||||
- Core
|
||||
responses:
|
||||
200:
|
||||
description: Basic information
|
||||
schema:
|
||||
$ref: '#/definitions/DiscoveryInfo'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/bootstrap:
|
||||
get:
|
||||
summary: Returns all data needed to bootstrap Home Assistant.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: Bootstrap information
|
||||
schema:
|
||||
$ref: '#/definitions/BootstrapInfo'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/events:
|
||||
get:
|
||||
summary: Array of event objects.
|
||||
description: Returns an array of event objects. Each event object contain event name and listener count.
|
||||
tags:
|
||||
- Events
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: Events
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Event'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/services:
|
||||
get:
|
||||
summary: Array of service objects.
|
||||
description: Returns an array of service objects. Each object contains the domain and which services it contains.
|
||||
tags:
|
||||
- Services
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: Services
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Service'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/history:
|
||||
get:
|
||||
summary: Array of state changes in the past.
|
||||
description: Returns an array of state changes in the past. Each object contains further detail for the entities.
|
||||
tags:
|
||||
- State
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: State changes
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/History'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/states:
|
||||
get:
|
||||
summary: Array of state objects.
|
||||
description: |
|
||||
Returns an array of state objects. Each state has the following attributes: entity_id, state, last_changed and attributes.
|
||||
tags:
|
||||
- State
|
||||
security:
|
||||
- api_key: []
|
||||
responses:
|
||||
200:
|
||||
description: States
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/State'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/states/{entity_id}:
|
||||
get:
|
||||
summary: Specific state object.
|
||||
description: |
|
||||
Returns a state object for specified entity_id.
|
||||
tags:
|
||||
- State
|
||||
security:
|
||||
- api_key: []
|
||||
parameters:
|
||||
- name: entity_id
|
||||
in: path
|
||||
description: entity_id of the entity to query
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: State
|
||||
schema:
|
||||
$ref: '#/definitions/State'
|
||||
404:
|
||||
description: Not found
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
post:
|
||||
description: |
|
||||
Updates or creates the current state of an entity.
|
||||
tags:
|
||||
- State
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- name: entity_id
|
||||
in: path
|
||||
description: entity_id to set the state of
|
||||
required: true
|
||||
type: string
|
||||
- $ref: '#/parameters/State'
|
||||
responses:
|
||||
200:
|
||||
description: State of existing entity was set
|
||||
schema:
|
||||
$ref: '#/definitions/State'
|
||||
201:
|
||||
description: State of new entity was set
|
||||
schema:
|
||||
$ref: '#/definitions/State'
|
||||
headers:
|
||||
location:
|
||||
type: string
|
||||
description: location of the new entity
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/error_log:
|
||||
get:
|
||||
summary: Error log
|
||||
description: |
|
||||
Retrieve all errors logged during the current session of Home Assistant as a plaintext response.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
200:
|
||||
description: Plain text error log
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/camera_proxy/camera.{entity_id}:
|
||||
get:
|
||||
summary: Camera image.
|
||||
description: |
|
||||
Returns the data (image) from the specified camera entity_id.
|
||||
tags:
|
||||
- Camera
|
||||
security:
|
||||
- api_key: []
|
||||
produces:
|
||||
- image/jpeg
|
||||
parameters:
|
||||
- name: entity_id
|
||||
in: path
|
||||
description: entity_id of the camera to query
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: Camera image
|
||||
schema:
|
||||
type: file
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/events/{event_type}:
|
||||
post:
|
||||
description: |
|
||||
Fires an event with event_type
|
||||
tags:
|
||||
- Events
|
||||
security:
|
||||
- api_key: []
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- name: event_type
|
||||
in: path
|
||||
description: event_type to fire event with
|
||||
required: true
|
||||
type: string
|
||||
- $ref: '#/parameters/EventData'
|
||||
responses:
|
||||
200:
|
||||
description: Response message
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/services/{domain}/{service}:
|
||||
post:
|
||||
description: |
|
||||
Calls a service within a specific domain. Will return when the service has been executed or 10 seconds has past, whichever comes first.
|
||||
tags:
|
||||
- Services
|
||||
security:
|
||||
- api_key: []
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- name: domain
|
||||
in: path
|
||||
description: domain of the service
|
||||
required: true
|
||||
type: string
|
||||
- name: service
|
||||
in: path
|
||||
description: service to call
|
||||
required: true
|
||||
type: string
|
||||
- $ref: '#/parameters/ServiceData'
|
||||
responses:
|
||||
200:
|
||||
description: List of states that have changed while the service was being executed. The result will include any changed states that changed while the service was being executed, even if their change was the result of something else happening in the system.
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/State'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/template:
|
||||
post:
|
||||
description: |
|
||||
Render a Home Assistant template.
|
||||
tags:
|
||||
- Template
|
||||
security:
|
||||
- api_key: []
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- text/plain
|
||||
parameters:
|
||||
- $ref: '#/parameters/Template'
|
||||
responses:
|
||||
200:
|
||||
description: Returns the rendered template in plain text.
|
||||
schema:
|
||||
type: string
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/event_forwarding:
|
||||
post:
|
||||
description: |
|
||||
Setup event forwarding to another Home Assistant instance.
|
||||
tags:
|
||||
- Core
|
||||
security:
|
||||
- api_key: []
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- $ref: '#/parameters/EventForwarding'
|
||||
responses:
|
||||
200:
|
||||
description: It will return a message if event forwarding was setup successful.
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
delete:
|
||||
description: |
|
||||
Cancel event forwarding to another Home Assistant instance.
|
||||
tags:
|
||||
- Core
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- $ref: '#/parameters/EventForwarding'
|
||||
responses:
|
||||
200:
|
||||
description: It will return a message if event forwarding was cancelled successful.
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
default:
|
||||
description: Error
|
||||
schema:
|
||||
$ref: '#/definitions/Message'
|
||||
/stream:
|
||||
get:
|
||||
summary: Server-sent events
|
||||
description: The server-sent events feature is a one-way channel from your Home Assistant server to a client which is acting as a consumer.
|
||||
tags:
|
||||
- Core
|
||||
- Events
|
||||
security:
|
||||
- api_key: []
|
||||
produces:
|
||||
- text/event-stream
|
||||
parameters:
|
||||
- name: restrict
|
||||
in: query
|
||||
description: comma-separated list of event_types to filter
|
||||
required: false
|
||||
type: string
|
||||
responses:
|
||||
default:
|
||||
description: Stream of events
|
||||
schema:
|
||||
type: object
|
||||
x-events:
|
||||
state_changed:
|
||||
type: object
|
||||
properties:
|
||||
entity_id:
|
||||
type: string
|
||||
old_state:
|
||||
$ref: '#/definitions/State'
|
||||
new_state:
|
||||
$ref: '#/definitions/State'
|
||||
definitions:
|
||||
ApiConfig:
|
||||
type: object
|
||||
properties:
|
||||
components:
|
||||
type: array
|
||||
description: List of component types
|
||||
items:
|
||||
type: string
|
||||
description: Component type
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
description: Latitude of Home Assistant server
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
description: Longitude of Home Assistant server
|
||||
location_name:
|
||||
type: string
|
||||
unit_system:
|
||||
type: object
|
||||
properties:
|
||||
length:
|
||||
type: string
|
||||
mass:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
volume:
|
||||
type: string
|
||||
time_zone:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
DiscoveryInfo:
|
||||
type: object
|
||||
properties:
|
||||
base_url:
|
||||
type: string
|
||||
location_name:
|
||||
type: string
|
||||
requires_api_password:
|
||||
type: boolean
|
||||
version:
|
||||
type: string
|
||||
BootstrapInfo:
|
||||
type: object
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/definitions/ApiConfig'
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Event'
|
||||
services:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Service'
|
||||
states:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/State'
|
||||
Event:
|
||||
type: object
|
||||
properties:
|
||||
event:
|
||||
type: string
|
||||
listener_count:
|
||||
type: integer
|
||||
Service:
|
||||
type: object
|
||||
properties:
|
||||
domain:
|
||||
type: string
|
||||
services:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/DomainService'
|
||||
DomainService:
|
||||
type: object
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
fields:
|
||||
type: object
|
||||
description: Object with service fields that can be called
|
||||
State:
|
||||
type: object
|
||||
properties:
|
||||
attributes:
|
||||
$ref: '#/definitions/StateAttributes'
|
||||
state:
|
||||
type: string
|
||||
entity_id:
|
||||
type: string
|
||||
last_changed:
|
||||
type: string
|
||||
format: date-time
|
||||
StateAttributes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
History:
|
||||
allOf:
|
||||
- $ref: '#/definitions/State'
|
||||
- type: object
|
||||
properties:
|
||||
last_updated:
|
||||
type: string
|
||||
format: date-time
|
||||
Message:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
parameters:
|
||||
State:
|
||||
name: body
|
||||
in: body
|
||||
description: State parameter
|
||||
required: false
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
properties:
|
||||
attributes:
|
||||
$ref: '#/definitions/StateAttributes'
|
||||
state:
|
||||
type: string
|
||||
EventData:
|
||||
name: body
|
||||
in: body
|
||||
description: event_data
|
||||
required: false
|
||||
schema:
|
||||
type: object
|
||||
ServiceData:
|
||||
name: body
|
||||
in: body
|
||||
description: service_data
|
||||
required: false
|
||||
schema:
|
||||
type: object
|
||||
Template:
|
||||
name: body
|
||||
in: body
|
||||
description: Template to render
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- template
|
||||
properties:
|
||||
template:
|
||||
description: Jinja2 template string
|
||||
type: string
|
||||
EventForwarding:
|
||||
name: body
|
||||
in: body
|
||||
description: Event Forwarding parameter
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- host
|
||||
- api_password
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
api_password:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
@ -8,7 +8,8 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any # noqa #pylint: disable=unused-import
|
||||
|
||||
|
||||
from homeassistant import monkey_patch
|
||||
from homeassistant.const import (
|
||||
@ -126,6 +127,10 @@ def get_arguments() -> argparse.Namespace:
|
||||
default=None,
|
||||
help='Log file to write to. If not set, CONFIG/home-assistant.log '
|
||||
'is used')
|
||||
parser.add_argument(
|
||||
'--log-no-color',
|
||||
action='store_true',
|
||||
help="Disable color logs")
|
||||
parser.add_argument(
|
||||
'--runner',
|
||||
action='store_true',
|
||||
@ -255,17 +260,18 @@ def setup_and_run_hass(config_dir: str,
|
||||
config = {
|
||||
'frontend': {},
|
||||
'demo': {}
|
||||
}
|
||||
} # type: Dict[str, Any]
|
||||
hass = bootstrap.from_config_dict(
|
||||
config, config_dir=config_dir, verbose=args.verbose,
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days,
|
||||
log_file=args.log_file)
|
||||
log_file=args.log_file, log_no_color=args.log_no_color)
|
||||
else:
|
||||
config_file = ensure_config_file(config_dir)
|
||||
print('Config directory:', config_dir)
|
||||
hass = bootstrap.from_config_file(
|
||||
config_file, verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days, log_file=args.log_file)
|
||||
log_rotate_days=args.log_rotate_days, log_file=args.log_file,
|
||||
log_no_color=args.log_no_color)
|
||||
|
||||
if hass is None:
|
||||
return None
|
||||
|
503
homeassistant/auth.py
Normal file
503
homeassistant/auth.py
Normal file
@ -0,0 +1,503 @@
|
||||
"""Provide an authentication layer for Home Assistant."""
|
||||
import asyncio
|
||||
import binascii
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import importlib
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import attr
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant import data_entry_flow, requirements
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
AUTH_PROVIDERS = Registry()
|
||||
|
||||
AUTH_PROVIDER_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Optional(CONF_NAME): str,
|
||||
# Specify ID if you have two auth providers for same type.
|
||||
vol.Optional(CONF_ID): str,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
|
||||
DATA_REQS = 'auth_reqs_processed'
|
||||
|
||||
|
||||
def generate_secret(entropy: int = 32) -> str:
|
||||
"""Generate a secret.
|
||||
|
||||
Backport of secrets.token_hex from Python 3.6
|
||||
|
||||
Event loop friendly.
|
||||
"""
|
||||
return binascii.hexlify(os.urandom(entropy)).decode('ascii')
|
||||
|
||||
|
||||
class AuthProvider:
|
||||
"""Provider of user authentication."""
|
||||
|
||||
DEFAULT_TITLE = 'Unnamed auth provider'
|
||||
|
||||
initialized = False
|
||||
|
||||
def __init__(self, hass, store, config):
|
||||
"""Initialize an auth provider."""
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def id(self): # pylint: disable=invalid-name
|
||||
"""Return id of the auth provider.
|
||||
|
||||
Optional, can be None.
|
||||
"""
|
||||
return self.config.get(CONF_ID)
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Return type of the provider."""
|
||||
return self.config[CONF_TYPE]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the auth provider."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
|
||||
async def async_credentials(self):
|
||||
"""Return all credentials of this provider."""
|
||||
return await self.store.credentials_for_provider(self.type, self.id)
|
||||
|
||||
@callback
|
||||
def async_create_credentials(self, data):
|
||||
"""Create credentials."""
|
||||
return Credentials(
|
||||
auth_provider_type=self.type,
|
||||
auth_provider_id=self.id,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Implement by extending class
|
||||
|
||||
async def async_initialize(self):
|
||||
"""Initialize the auth provider.
|
||||
|
||||
Optional.
|
||||
"""
|
||||
|
||||
async def async_credential_flow(self):
|
||||
"""Return the data flow for logging in with auth provider."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
"""Get credentials based on the flow result."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class User:
|
||||
"""A user."""
|
||||
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_owner = attr.ib(type=bool, default=False)
|
||||
is_active = attr.ib(type=bool, default=False)
|
||||
name = attr.ib(type=str, default=None)
|
||||
# For persisting and see if saved?
|
||||
# store = attr.ib(type=AuthStore, default=None)
|
||||
|
||||
# List of credentials of a user.
|
||||
credentials = attr.ib(type=list, default=attr.Factory(list))
|
||||
|
||||
# Tokens associated with a user.
|
||||
refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict))
|
||||
|
||||
def as_dict(self):
|
||||
"""Convert user object to a dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'is_owner': self.is_owner,
|
||||
'is_active': self.is_active,
|
||||
'name': self.name,
|
||||
}
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class RefreshToken:
|
||||
"""RefreshToken for a user to grant new access tokens."""
|
||||
|
||||
user = attr.ib(type=User)
|
||||
client_id = attr.ib(type=str)
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow))
|
||||
access_token_expiration = attr.ib(type=timedelta,
|
||||
default=ACCESS_TOKEN_EXPIRATION)
|
||||
token = attr.ib(type=str,
|
||||
default=attr.Factory(lambda: generate_secret(64)))
|
||||
access_tokens = attr.ib(type=list, default=attr.Factory(list))
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class AccessToken:
|
||||
"""Access token to access the API.
|
||||
|
||||
These will only ever be stored in memory and not be persisted.
|
||||
"""
|
||||
|
||||
refresh_token = attr.ib(type=RefreshToken)
|
||||
created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow))
|
||||
token = attr.ib(type=str,
|
||||
default=attr.Factory(generate_secret))
|
||||
|
||||
@property
|
||||
def expires(self):
|
||||
"""Return datetime when this token expires."""
|
||||
return self.created_at + self.refresh_token.access_token_expiration
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Credentials:
|
||||
"""Credentials for a user on an auth provider."""
|
||||
|
||||
auth_provider_type = attr.ib(type=str)
|
||||
auth_provider_id = attr.ib(type=str)
|
||||
|
||||
# Allow the auth provider to store data to represent their auth.
|
||||
data = attr.ib(type=dict)
|
||||
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_new = attr.ib(type=bool, default=True)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Client:
|
||||
"""Client that interacts with Home Assistant on behalf of a user."""
|
||||
|
||||
name = attr.ib(type=str)
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
secret = attr.ib(type=str, default=attr.Factory(generate_secret))
|
||||
redirect_uris = attr.ib(type=list, default=attr.Factory(list))
|
||||
|
||||
|
||||
async def load_auth_provider_module(hass, provider):
|
||||
"""Load an auth provider."""
|
||||
try:
|
||||
module = importlib.import_module(
|
||||
'homeassistant.auth_providers.{}'.format(provider))
|
||||
except ImportError:
|
||||
_LOGGER.warning('Unable to find auth provider %s', provider)
|
||||
return None
|
||||
|
||||
if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'):
|
||||
return module
|
||||
|
||||
processed = hass.data.get(DATA_REQS)
|
||||
|
||||
if processed is None:
|
||||
processed = hass.data[DATA_REQS] = set()
|
||||
elif provider in processed:
|
||||
return module
|
||||
|
||||
req_success = await requirements.async_process_requirements(
|
||||
hass, 'auth provider {}'.format(provider), module.REQUIREMENTS)
|
||||
|
||||
if not req_success:
|
||||
return None
|
||||
|
||||
return module
|
||||
|
||||
|
||||
async def auth_manager_from_config(hass, provider_configs):
|
||||
"""Initialize an auth manager from config."""
|
||||
store = AuthStore(hass)
|
||||
if provider_configs:
|
||||
providers = await asyncio.gather(
|
||||
*[_auth_provider_from_config(hass, store, config)
|
||||
for config in provider_configs])
|
||||
else:
|
||||
providers = []
|
||||
# So returned auth providers are in same order as config
|
||||
provider_hash = OrderedDict()
|
||||
for provider in providers:
|
||||
if provider is None:
|
||||
continue
|
||||
|
||||
key = (provider.type, provider.id)
|
||||
|
||||
if key in provider_hash:
|
||||
_LOGGER.error(
|
||||
'Found duplicate provider: %s. Please add unique IDs if you '
|
||||
'want to have the same provider twice.', key)
|
||||
continue
|
||||
|
||||
provider_hash[key] = provider
|
||||
manager = AuthManager(hass, store, provider_hash)
|
||||
return manager
|
||||
|
||||
|
||||
async def _auth_provider_from_config(hass, store, config):
|
||||
"""Initialize an auth provider from a config."""
|
||||
provider_name = config[CONF_TYPE]
|
||||
module = await load_auth_provider_module(hass, provider_name)
|
||||
|
||||
if module is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
config = module.CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Invalid configuration for auth provider %s: %s',
|
||||
provider_name, humanize_error(config, err))
|
||||
return None
|
||||
|
||||
return AUTH_PROVIDERS[provider_name](hass, store, config)
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manage the authentication for Home Assistant."""
|
||||
|
||||
def __init__(self, hass, store, providers):
|
||||
"""Initialize the auth manager."""
|
||||
self._store = store
|
||||
self._providers = providers
|
||||
self.login_flow = data_entry_flow.FlowManager(
|
||||
hass, self._async_create_login_flow,
|
||||
self._async_finish_login_flow)
|
||||
self.access_tokens = {}
|
||||
|
||||
@property
|
||||
def async_auth_providers(self):
|
||||
"""Return a list of available auth providers."""
|
||||
return self._providers.values()
|
||||
|
||||
async def async_get_user(self, user_id):
|
||||
"""Retrieve a user."""
|
||||
return await self._store.async_get_user(user_id)
|
||||
|
||||
async def async_get_or_create_user(self, credentials):
|
||||
"""Get or create a user."""
|
||||
return await self._store.async_get_or_create_user(
|
||||
credentials, self._async_get_auth_provider(credentials))
|
||||
|
||||
async def async_link_user(self, user, credentials):
|
||||
"""Link credentials to an existing user."""
|
||||
await self._store.async_link_user(user, credentials)
|
||||
|
||||
async def async_remove_user(self, user):
|
||||
"""Remove a user."""
|
||||
await self._store.async_remove_user(user)
|
||||
|
||||
async def async_create_refresh_token(self, user, client_id):
|
||||
"""Create a new refresh token for a user."""
|
||||
return await self._store.async_create_refresh_token(user, client_id)
|
||||
|
||||
async def async_get_refresh_token(self, token):
|
||||
"""Get refresh token by token."""
|
||||
return await self._store.async_get_refresh_token(token)
|
||||
|
||||
@callback
|
||||
def async_create_access_token(self, refresh_token):
|
||||
"""Create a new access token."""
|
||||
access_token = AccessToken(refresh_token)
|
||||
self.access_tokens[access_token.token] = access_token
|
||||
return access_token
|
||||
|
||||
@callback
|
||||
def async_get_access_token(self, token):
|
||||
"""Get an access token."""
|
||||
return self.access_tokens.get(token)
|
||||
|
||||
async def async_create_client(self, name, *, redirect_uris=None,
|
||||
no_secret=False):
|
||||
"""Create a new client."""
|
||||
return await self._store.async_create_client(
|
||||
name, redirect_uris, no_secret)
|
||||
|
||||
async def async_get_client(self, client_id):
|
||||
"""Get a client."""
|
||||
return await self._store.async_get_client(client_id)
|
||||
|
||||
async def _async_create_login_flow(self, handler, *, source, data):
|
||||
"""Create a login flow."""
|
||||
auth_provider = self._providers[handler]
|
||||
|
||||
if not auth_provider.initialized:
|
||||
auth_provider.initialized = True
|
||||
await auth_provider.async_initialize()
|
||||
|
||||
return await auth_provider.async_credential_flow()
|
||||
|
||||
async def _async_finish_login_flow(self, result):
|
||||
"""Result of a credential login flow."""
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
return None
|
||||
|
||||
auth_provider = self._providers[result['handler']]
|
||||
return await auth_provider.async_get_or_create_credentials(
|
||||
result['data'])
|
||||
|
||||
@callback
|
||||
def _async_get_auth_provider(self, credentials):
|
||||
"""Helper to get auth provider from a set of credentials."""
|
||||
auth_provider_key = (credentials.auth_provider_type,
|
||||
credentials.auth_provider_id)
|
||||
return self._providers[auth_provider_key]
|
||||
|
||||
|
||||
class AuthStore:
|
||||
"""Stores authentication info.
|
||||
|
||||
Any mutation to an object should happen inside the auth store.
|
||||
|
||||
The auth store is lazy. It won't load the data from disk until a method is
|
||||
called that needs it.
|
||||
"""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize the auth store."""
|
||||
self.hass = hass
|
||||
self.users = None
|
||||
self.clients = None
|
||||
self._load_lock = asyncio.Lock(loop=hass.loop)
|
||||
|
||||
async def credentials_for_provider(self, provider_type, provider_id):
|
||||
"""Return credentials for specific auth provider type and id."""
|
||||
if self.users is None:
|
||||
await self.async_load()
|
||||
|
||||
return [
|
||||
credentials
|
||||
for user in self.users.values()
|
||||
for credentials in user.credentials
|
||||
if (credentials.auth_provider_type == provider_type and
|
||||
credentials.auth_provider_id == provider_id)
|
||||
]
|
||||
|
||||
async def async_get_user(self, user_id):
|
||||
"""Retrieve a user."""
|
||||
if self.users is None:
|
||||
await self.async_load()
|
||||
|
||||
return self.users.get(user_id)
|
||||
|
||||
async def async_get_or_create_user(self, credentials, auth_provider):
|
||||
"""Get or create a new user for given credentials.
|
||||
|
||||
If link_user is passed in, the credentials will be linked to the passed
|
||||
in user if the credentials are new.
|
||||
"""
|
||||
if self.users is None:
|
||||
await self.async_load()
|
||||
|
||||
# New credentials, store in user
|
||||
if credentials.is_new:
|
||||
info = await auth_provider.async_user_meta_for_credentials(
|
||||
credentials)
|
||||
# Make owner and activate user if it's the first user.
|
||||
if self.users:
|
||||
is_owner = False
|
||||
is_active = False
|
||||
else:
|
||||
is_owner = True
|
||||
is_active = True
|
||||
|
||||
new_user = User(
|
||||
is_owner=is_owner,
|
||||
is_active=is_active,
|
||||
name=info.get('name'),
|
||||
)
|
||||
self.users[new_user.id] = new_user
|
||||
await self.async_link_user(new_user, credentials)
|
||||
return new_user
|
||||
|
||||
for user in self.users.values():
|
||||
for creds in user.credentials:
|
||||
if (creds.auth_provider_type == credentials.auth_provider_type
|
||||
and creds.auth_provider_id ==
|
||||
credentials.auth_provider_id):
|
||||
return user
|
||||
|
||||
raise ValueError('We got credentials with ID but found no user')
|
||||
|
||||
async def async_link_user(self, user, credentials):
|
||||
"""Add credentials to an existing user."""
|
||||
user.credentials.append(credentials)
|
||||
await self.async_save()
|
||||
credentials.is_new = False
|
||||
|
||||
async def async_remove_user(self, user):
|
||||
"""Remove a user."""
|
||||
self.users.pop(user.id)
|
||||
await self.async_save()
|
||||
|
||||
async def async_create_refresh_token(self, user, client_id):
|
||||
"""Create a new token for a user."""
|
||||
refresh_token = RefreshToken(user, client_id)
|
||||
user.refresh_tokens[refresh_token.token] = refresh_token
|
||||
await self.async_save()
|
||||
return refresh_token
|
||||
|
||||
async def async_get_refresh_token(self, token):
|
||||
"""Get refresh token by token."""
|
||||
if self.users is None:
|
||||
await self.async_load()
|
||||
|
||||
for user in self.users.values():
|
||||
refresh_token = user.refresh_tokens.get(token)
|
||||
if refresh_token is not None:
|
||||
return refresh_token
|
||||
|
||||
return None
|
||||
|
||||
async def async_create_client(self, name, redirect_uris, no_secret):
|
||||
"""Create a new client."""
|
||||
if self.clients is None:
|
||||
await self.async_load()
|
||||
|
||||
kwargs = {
|
||||
'name': name,
|
||||
'redirect_uris': redirect_uris
|
||||
}
|
||||
|
||||
if no_secret:
|
||||
kwargs['secret'] = None
|
||||
|
||||
client = Client(**kwargs)
|
||||
self.clients[client.id] = client
|
||||
await self.async_save()
|
||||
return client
|
||||
|
||||
async def async_get_client(self, client_id):
|
||||
"""Get a client."""
|
||||
if self.clients is None:
|
||||
await self.async_load()
|
||||
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def async_load(self):
|
||||
"""Load the users."""
|
||||
async with self._load_lock:
|
||||
self.users = {}
|
||||
self.clients = {}
|
||||
|
||||
async def async_save(self):
|
||||
"""Save users."""
|
||||
pass
|
1
homeassistant/auth_providers/__init__.py
Normal file
1
homeassistant/auth_providers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Auth providers for Home Assistant."""
|
181
homeassistant/auth_providers/homeassistant.py
Normal file
181
homeassistant/auth_providers/homeassistant.py
Normal file
@ -0,0 +1,181 @@
|
||||
"""Home Assistant auth provider."""
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import auth, data_entry_flow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import json
|
||||
|
||||
|
||||
PATH_DATA = '.users.json'
|
||||
|
||||
CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Raised when we encounter invalid authentication."""
|
||||
|
||||
|
||||
class InvalidUser(HomeAssistantError):
|
||||
"""Raised when invalid user is specified.
|
||||
|
||||
Will not be raised when validating authentication.
|
||||
"""
|
||||
|
||||
|
||||
class Data:
|
||||
"""Hold the user data."""
|
||||
|
||||
def __init__(self, path, data):
|
||||
"""Initialize the user data store."""
|
||||
self.path = path
|
||||
if data is None:
|
||||
data = {
|
||||
'salt': auth.generate_secret(),
|
||||
'users': []
|
||||
}
|
||||
self._data = data
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
"""Return users."""
|
||||
return self._data['users']
|
||||
|
||||
def validate_login(self, username, password):
|
||||
"""Validate a username and password.
|
||||
|
||||
Raises InvalidAuth if auth invalid.
|
||||
"""
|
||||
password = self.hash_password(password)
|
||||
|
||||
found = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
for user in self._data['users']:
|
||||
if username == user['username']:
|
||||
found = user
|
||||
|
||||
if found is None:
|
||||
# Do one more compare to make timing the same as if user was found.
|
||||
hmac.compare_digest(password, password)
|
||||
raise InvalidAuth
|
||||
|
||||
if not hmac.compare_digest(password,
|
||||
base64.b64decode(found['password'])):
|
||||
raise InvalidAuth
|
||||
|
||||
def hash_password(self, password, for_storage=False):
|
||||
"""Encode a password."""
|
||||
hashed = hashlib.pbkdf2_hmac(
|
||||
'sha512', password.encode(), self._data['salt'].encode(), 100000)
|
||||
if for_storage:
|
||||
hashed = base64.b64encode(hashed).decode()
|
||||
return hashed
|
||||
|
||||
def add_user(self, username, password):
|
||||
"""Add a user."""
|
||||
if any(user['username'] == username for user in self.users):
|
||||
raise InvalidUser
|
||||
|
||||
self.users.append({
|
||||
'username': username,
|
||||
'password': self.hash_password(password, True),
|
||||
})
|
||||
|
||||
def change_password(self, username, new_password):
|
||||
"""Update the password of a user.
|
||||
|
||||
Raises InvalidUser if user cannot be found.
|
||||
"""
|
||||
for user in self.users:
|
||||
if user['username'] == username:
|
||||
user['password'] = self.hash_password(new_password, True)
|
||||
break
|
||||
else:
|
||||
raise InvalidUser
|
||||
|
||||
def save(self):
|
||||
"""Save data."""
|
||||
json.save_json(self.path, self._data)
|
||||
|
||||
|
||||
def load_data(path):
|
||||
"""Load auth data."""
|
||||
return Data(path, json.load_json(path, None))
|
||||
|
||||
|
||||
@auth.AUTH_PROVIDERS.register('homeassistant')
|
||||
class HassAuthProvider(auth.AuthProvider):
|
||||
"""Auth provider based on a local storage of users in HASS config dir."""
|
||||
|
||||
DEFAULT_TITLE = 'Home Assistant Local'
|
||||
|
||||
async def async_credential_flow(self):
|
||||
"""Return a flow to login."""
|
||||
return LoginFlow(self)
|
||||
|
||||
async def async_validate_login(self, username, password):
|
||||
"""Helper to validate a username and password."""
|
||||
def validate():
|
||||
"""Validate creds."""
|
||||
data = self._auth_data()
|
||||
data.validate_login(username, password)
|
||||
|
||||
await self.hass.async_add_job(validate)
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result['username']
|
||||
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data['username'] == username:
|
||||
return credential
|
||||
|
||||
# Create new credentials.
|
||||
return self.async_create_credentials({
|
||||
'username': username
|
||||
})
|
||||
|
||||
def _auth_data(self):
|
||||
"""Return the auth provider data."""
|
||||
return load_data(self.hass.config.path(PATH_DATA))
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
def __init__(self, auth_provider):
|
||||
"""Initialize the login flow."""
|
||||
self._auth_provider = auth_provider
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self._auth_provider.async_validate_login(
|
||||
user_input['username'], user_input['password'])
|
||||
except InvalidAuth:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=user_input
|
||||
)
|
||||
|
||||
schema = OrderedDict()
|
||||
schema['username'] = str
|
||||
schema['password'] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
118
homeassistant/auth_providers/insecure_example.py
Normal file
118
homeassistant/auth_providers/insecure_example.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Example auth provider."""
|
||||
from collections import OrderedDict
|
||||
import hmac
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant import auth, data_entry_flow
|
||||
from homeassistant.core import callback
|
||||
|
||||
|
||||
USER_SCHEMA = vol.Schema({
|
||||
vol.Required('username'): str,
|
||||
vol.Required('password'): str,
|
||||
vol.Optional('name'): str,
|
||||
})
|
||||
|
||||
|
||||
CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({
|
||||
vol.Required('users'): [USER_SCHEMA]
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
@auth.AUTH_PROVIDERS.register('insecure_example')
|
||||
class ExampleAuthProvider(auth.AuthProvider):
|
||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
||||
|
||||
async def async_credential_flow(self):
|
||||
"""Return a flow to login."""
|
||||
return LoginFlow(self)
|
||||
|
||||
@callback
|
||||
def async_validate_login(self, username, password):
|
||||
"""Helper to validate a username and password."""
|
||||
user = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
for usr in self.config['users']:
|
||||
if hmac.compare_digest(username.encode('utf-8'),
|
||||
usr['username'].encode('utf-8')):
|
||||
user = usr
|
||||
|
||||
if user is None:
|
||||
# Do one more compare to make timing the same as if user was found.
|
||||
hmac.compare_digest(password.encode('utf-8'),
|
||||
password.encode('utf-8'))
|
||||
raise InvalidAuthError
|
||||
|
||||
if not hmac.compare_digest(user['password'].encode('utf-8'),
|
||||
password.encode('utf-8')):
|
||||
raise InvalidAuthError
|
||||
|
||||
async def async_get_or_create_credentials(self, flow_result):
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result['username']
|
||||
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data['username'] == username:
|
||||
return credential
|
||||
|
||||
# Create new credentials.
|
||||
return self.async_create_credentials({
|
||||
'username': username
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(self, credentials):
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
username = credentials.data['username']
|
||||
|
||||
for user in self.config['users']:
|
||||
if user['username'] == username:
|
||||
return {
|
||||
'name': user.get('name')
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
class LoginFlow(data_entry_flow.FlowHandler):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
def __init__(self, auth_provider):
|
||||
"""Initialize the login flow."""
|
||||
self._auth_provider = auth_provider
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
self._auth_provider.async_validate_login(
|
||||
user_input['username'], user_input['password'])
|
||||
except InvalidAuthError:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=user_input
|
||||
)
|
||||
|
||||
schema = OrderedDict()
|
||||
schema['username'] = str
|
||||
schema['password'] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
@ -12,8 +12,7 @@ from typing import Any, Optional, Dict
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import (
|
||||
core, config as conf_util, config_entries, loader,
|
||||
components as core_components)
|
||||
core, config as conf_util, config_entries, components as core_components)
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
|
||||
from homeassistant.setup import async_setup_component
|
||||
@ -42,7 +41,8 @@ def from_config_dict(config: Dict[str, Any],
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = False,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None) \
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False) \
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Try to configure Home Assistant from a configuration dictionary.
|
||||
|
||||
@ -60,21 +60,21 @@ def from_config_dict(config: Dict[str, Any],
|
||||
hass = hass.loop.run_until_complete(
|
||||
async_from_config_dict(
|
||||
config, hass, config_dir, enable_log, verbose, skip_pip,
|
||||
log_rotate_days, log_file)
|
||||
log_rotate_days, log_file, log_no_color)
|
||||
)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_from_config_dict(config: Dict[str, Any],
|
||||
async def async_from_config_dict(config: Dict[str, Any],
|
||||
hass: core.HomeAssistant,
|
||||
config_dir: Optional[str] = None,
|
||||
enable_log: bool = True,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = False,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None) \
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False) \
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Try to configure Home Assistant from a configuration dictionary.
|
||||
|
||||
@ -84,40 +84,30 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
start = time()
|
||||
|
||||
if enable_log:
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file)
|
||||
|
||||
if sys.version_info[:2] < (3, 5):
|
||||
_LOGGER.warning(
|
||||
'Python 3.4 support has been deprecated and will be removed in '
|
||||
'the beginning of 2018. Please upgrade Python or your operating '
|
||||
'system. More info: https://home-assistant.io/blog/2017/10/06/'
|
||||
'deprecating-python-3.4-support/'
|
||||
)
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file,
|
||||
log_no_color)
|
||||
|
||||
core_config = config.get(core.DOMAIN, {})
|
||||
|
||||
try:
|
||||
yield from conf_util.async_process_ha_core_config(hass, core_config)
|
||||
await conf_util.async_process_ha_core_config(hass, core_config)
|
||||
except vol.Invalid as ex:
|
||||
conf_util.async_log_exception(ex, 'homeassistant', core_config, hass)
|
||||
return None
|
||||
|
||||
yield from hass.async_add_job(conf_util.process_ha_config_upgrade, hass)
|
||||
await hass.async_add_job(conf_util.process_ha_config_upgrade, hass)
|
||||
|
||||
hass.config.skip_pip = skip_pip
|
||||
if skip_pip:
|
||||
_LOGGER.warning("Skipping pip installation of required modules. "
|
||||
"This may cause issues")
|
||||
|
||||
if not loader.PREPARED:
|
||||
yield from hass.async_add_job(loader.prepare, hass)
|
||||
|
||||
# Make a copy because we are mutating it.
|
||||
config = OrderedDict(config)
|
||||
|
||||
# Merge packages
|
||||
conf_util.merge_packages_config(
|
||||
config, core_config.get(conf_util.CONF_PACKAGES, {}))
|
||||
hass, config, core_config.get(conf_util.CONF_PACKAGES, {}))
|
||||
|
||||
# Ensure we have no None values after merge
|
||||
for key, value in config.items():
|
||||
@ -125,7 +115,7 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
config[key] = {}
|
||||
|
||||
hass.config_entries = config_entries.ConfigEntries(hass, config)
|
||||
yield from hass.config_entries.async_load()
|
||||
await hass.config_entries.async_load()
|
||||
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
components = set(key.split(' ')[0] for key in config.keys()
|
||||
@ -134,13 +124,13 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
|
||||
# setup components
|
||||
# pylint: disable=not-an-iterable
|
||||
res = yield from core_components.async_setup(hass, config)
|
||||
res = await core_components.async_setup(hass, config)
|
||||
if not res:
|
||||
_LOGGER.error("Home Assistant core failed to initialize. "
|
||||
"further initialization aborted")
|
||||
return hass
|
||||
|
||||
yield from persistent_notification.async_setup(hass, config)
|
||||
await persistent_notification.async_setup(hass, config)
|
||||
|
||||
_LOGGER.info("Home Assistant core initialized")
|
||||
|
||||
@ -150,7 +140,7 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
continue
|
||||
hass.async_add_job(async_setup_component(hass, component, config))
|
||||
|
||||
yield from hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# stage 2
|
||||
for component in components:
|
||||
@ -158,7 +148,7 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
continue
|
||||
hass.async_add_job(async_setup_component(hass, component, config))
|
||||
|
||||
yield from hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
stop = time()
|
||||
_LOGGER.info("Home Assistant initialized in %.2fs", stop-start)
|
||||
@ -172,7 +162,8 @@ def from_config_file(config_path: str,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = True,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None):
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False):
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter if given,
|
||||
@ -184,19 +175,20 @@ def from_config_file(config_path: str,
|
||||
# run task
|
||||
hass = hass.loop.run_until_complete(
|
||||
async_from_config_file(
|
||||
config_path, hass, verbose, skip_pip, log_rotate_days, log_file)
|
||||
config_path, hass, verbose, skip_pip,
|
||||
log_rotate_days, log_file, log_no_color)
|
||||
)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_from_config_file(config_path: str,
|
||||
async def async_from_config_file(config_path: str,
|
||||
hass: core.HomeAssistant,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = True,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None):
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False):
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter.
|
||||
@ -205,12 +197,13 @@ def async_from_config_file(config_path: str,
|
||||
# Set config dir to directory holding config file
|
||||
config_dir = os.path.abspath(os.path.dirname(config_path))
|
||||
hass.config.config_dir = config_dir
|
||||
yield from async_mount_local_lib_path(config_dir, hass.loop)
|
||||
await async_mount_local_lib_path(config_dir, hass.loop)
|
||||
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file)
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file,
|
||||
log_no_color)
|
||||
|
||||
try:
|
||||
config_dict = yield from hass.async_add_job(
|
||||
config_dict = await hass.async_add_job(
|
||||
conf_util.load_yaml_config_file, config_path)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error("Error loading %s: %s", config_path, err)
|
||||
@ -218,31 +211,33 @@ def async_from_config_file(config_path: str,
|
||||
finally:
|
||||
clear_secret_cache()
|
||||
|
||||
hass = yield from async_from_config_dict(
|
||||
hass = await async_from_config_dict(
|
||||
config_dict, hass, enable_log=False, skip_pip=skip_pip)
|
||||
return hass
|
||||
|
||||
|
||||
@core.callback
|
||||
def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False,
|
||||
log_rotate_days=None, log_file=None) -> None:
|
||||
def async_enable_logging(hass: core.HomeAssistant,
|
||||
verbose: bool = False,
|
||||
log_rotate_days=None,
|
||||
log_file=None,
|
||||
log_no_color: bool = False) -> None:
|
||||
"""Set up the logging.
|
||||
|
||||
This method must be run in the event loop.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
fmt = ("%(asctime)s %(levelname)s (%(threadName)s) "
|
||||
"[%(name)s] %(message)s")
|
||||
colorfmt = "%(log_color)s{}%(reset)s".format(fmt)
|
||||
datefmt = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# Suppress overly verbose logs from libraries that aren't helpful
|
||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
||||
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
||||
|
||||
if not log_no_color:
|
||||
try:
|
||||
from colorlog import ColoredFormatter
|
||||
# basicConfig must be called after importing colorlog in order to
|
||||
# ensure that the handlers it sets up wraps the correct streams.
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
colorfmt = "%(log_color)s{}%(reset)s".format(fmt)
|
||||
logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
|
||||
colorfmt,
|
||||
datefmt=datefmt,
|
||||
@ -258,6 +253,15 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False,
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# If the above initialization failed for any reason, setup the default
|
||||
# formatting. If the above succeeds, this wil result in a no-op.
|
||||
logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO)
|
||||
|
||||
# Suppress overly verbose logs from libraries that aren't helpful
|
||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
||||
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
if log_file is None:
|
||||
err_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
@ -274,7 +278,8 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False,
|
||||
|
||||
if log_rotate_days:
|
||||
err_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
err_log_path, when='midnight', backupCount=log_rotate_days)
|
||||
err_log_path, when='midnight',
|
||||
backupCount=log_rotate_days) # type: logging.FileHandler
|
||||
else:
|
||||
err_handler = logging.FileHandler(
|
||||
err_log_path, mode='w', delay=True)
|
||||
@ -284,17 +289,16 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False,
|
||||
|
||||
async_handler = AsyncHandler(hass.loop, err_handler)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_stop_async_handler(event):
|
||||
async def async_stop_async_handler(event):
|
||||
"""Cleanup async handler."""
|
||||
logging.getLogger('').removeHandler(async_handler)
|
||||
yield from async_handler.async_close(blocking=True)
|
||||
await async_handler.async_close(blocking=True)
|
||||
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler)
|
||||
|
||||
logger = logging.getLogger('')
|
||||
logger.addHandler(async_handler)
|
||||
logger.addHandler(async_handler) # type: ignore
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Save the log file location for access by other components.
|
||||
@ -313,15 +317,14 @@ def mount_local_lib_path(config_dir: str) -> str:
|
||||
return deps_dir
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_mount_local_lib_path(config_dir: str,
|
||||
async def async_mount_local_lib_path(config_dir: str,
|
||||
loop: asyncio.AbstractEventLoop) -> str:
|
||||
"""Add local library to Python Path.
|
||||
|
||||
This function is a coroutine.
|
||||
"""
|
||||
deps_dir = os.path.join(config_dir, 'deps')
|
||||
lib_dir = yield from async_get_user_site(deps_dir, loop=loop)
|
||||
lib_dir = await async_get_user_site(deps_dir, loop=loop)
|
||||
if lib_dir not in sys.path:
|
||||
sys.path.insert(0, lib_dir)
|
||||
return deps_dir
|
||||
|
@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
REQUIREMENTS = ['abodepy==0.12.2']
|
||||
REQUIREMENTS = ['abodepy==0.13.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -27,6 +27,7 @@ CONF_ATTRIBUTION = "Data provided by goabode.com"
|
||||
CONF_POLLING = 'polling'
|
||||
|
||||
DOMAIN = 'abode'
|
||||
DEFAULT_CACHEDB = './abodepy_cache.pickle'
|
||||
|
||||
NOTIFICATION_ID = 'abode_notification'
|
||||
NOTIFICATION_TITLE = 'Abode Security Setup'
|
||||
@ -80,19 +81,20 @@ TRIGGER_SCHEMA = vol.Schema({
|
||||
|
||||
ABODE_PLATFORMS = [
|
||||
'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover',
|
||||
'camera', 'light'
|
||||
'camera', 'light', 'sensor'
|
||||
]
|
||||
|
||||
|
||||
class AbodeSystem(object):
|
||||
"""Abode System class."""
|
||||
|
||||
def __init__(self, username, password, name, polling, exclude, lights):
|
||||
def __init__(self, username, password, cache,
|
||||
name, polling, exclude, lights):
|
||||
"""Initialize the system."""
|
||||
import abodepy
|
||||
self.abode = abodepy.Abode(
|
||||
username, password, auto_login=True, get_devices=True,
|
||||
get_automations=True)
|
||||
get_automations=True, cache_path=cache)
|
||||
self.name = name
|
||||
self.polling = polling
|
||||
self.exclude = exclude
|
||||
@ -129,8 +131,9 @@ def setup(hass, config):
|
||||
lights = conf.get(CONF_LIGHTS)
|
||||
|
||||
try:
|
||||
cache = hass.config.path(DEFAULT_CACHEDB)
|
||||
hass.data[DOMAIN] = AbodeSystem(
|
||||
username, password, name, polling, exclude, lights)
|
||||
username, password, cache, name, polling, exclude, lights)
|
||||
except (AbodeException, ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Abode: %s", str(ex))
|
||||
|
||||
|
@ -100,8 +100,8 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the regex for code format or None if no code is required."""
|
||||
return '^\\d{4,6}$'
|
||||
"""Return one or more digits/characters."""
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
@ -6,6 +6,7 @@ https://home-assistant.io/components/alarm_control_panel.alarmdotcom/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -17,7 +18,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyalarmdotcom==0.3.1']
|
||||
REQUIREMENTS = ['pyalarmdotcom==0.3.2']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -79,8 +80,12 @@ class AlarmDotCom(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters if code is defined."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
elif isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
@ -93,6 +98,13 @@ class AlarmDotCom(alarm.AlarmControlPanel):
|
||||
return STATE_ALARM_ARMED_AWAY
|
||||
return STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
'sensor_status': self._alarm.sensor_status
|
||||
}
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
|
@ -80,7 +80,7 @@ class Concord232Alarm(alarm.AlarmControlPanel):
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the characters if code is defined."""
|
||||
return '[0-9]{4}([0-9]{2})?'
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
@ -12,13 +12,14 @@ import requests
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED)
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED,
|
||||
STATE_ALARM_ARMED_NIGHT)
|
||||
from homeassistant.components.egardia import (
|
||||
EGARDIA_DEVICE, EGARDIA_SERVER,
|
||||
REPORT_SERVER_CODES_IGNORE, CONF_REPORT_SERVER_CODES,
|
||||
CONF_REPORT_SERVER_ENABLED, CONF_REPORT_SERVER_PORT
|
||||
)
|
||||
REQUIREMENTS = ['pythonegardia==1.0.38']
|
||||
DEPENDENCIES = ['egardia']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -27,6 +28,8 @@ STATES = {
|
||||
'DAY HOME': STATE_ALARM_ARMED_HOME,
|
||||
'DISARM': STATE_ALARM_DISARMED,
|
||||
'ARMHOME': STATE_ALARM_ARMED_HOME,
|
||||
'HOME': STATE_ALARM_ARMED_HOME,
|
||||
'NIGHT HOME': STATE_ALARM_ARMED_NIGHT,
|
||||
'TRIGGERED': STATE_ALARM_TRIGGERED
|
||||
}
|
||||
|
||||
|
@ -106,7 +106,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel):
|
||||
"""Regex for code format or None if no code is required."""
|
||||
if self._code:
|
||||
return None
|
||||
return '^\\d{4,6}$'
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
175
homeassistant/components/alarm_control_panel/ifttt.py
Normal file
175
homeassistant/components/alarm_control_panel/ifttt.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""
|
||||
Interfaces with alarm control panels that have to be controlled through IFTTT.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.ifttt/
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
DOMAIN, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.ifttt import (
|
||||
ATTR_EVENT, DOMAIN as IFTTT_DOMAIN, SERVICE_TRIGGER)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_STATE, CONF_NAME, CONF_CODE,
|
||||
CONF_OPTIMISTIC, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_NIGHT,
|
||||
STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DEPENDENCIES = ['ifttt']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_STATES = [
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_ARMED_NIGHT,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME]
|
||||
|
||||
DATA_IFTTT_ALARM = 'ifttt_alarm'
|
||||
DEFAULT_NAME = "Home"
|
||||
|
||||
CONF_EVENT_AWAY = "event_arm_away"
|
||||
CONF_EVENT_HOME = "event_arm_home"
|
||||
CONF_EVENT_NIGHT = "event_arm_night"
|
||||
CONF_EVENT_DISARM = "event_disarm"
|
||||
|
||||
DEFAULT_EVENT_AWAY = "alarm_arm_away"
|
||||
DEFAULT_EVENT_HOME = "alarm_arm_home"
|
||||
DEFAULT_EVENT_NIGHT = "alarm_arm_night"
|
||||
DEFAULT_EVENT_DISARM = "alarm_disarm"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_CODE): cv.string,
|
||||
vol.Optional(CONF_EVENT_AWAY, default=DEFAULT_EVENT_AWAY): cv.string,
|
||||
vol.Optional(CONF_EVENT_HOME, default=DEFAULT_EVENT_HOME): cv.string,
|
||||
vol.Optional(CONF_EVENT_NIGHT, default=DEFAULT_EVENT_NIGHT): cv.string,
|
||||
vol.Optional(CONF_EVENT_DISARM, default=DEFAULT_EVENT_DISARM): cv.string,
|
||||
vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
|
||||
})
|
||||
|
||||
SERVICE_PUSH_ALARM_STATE = "ifttt_push_alarm_state"
|
||||
|
||||
PUSH_ALARM_STATE_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Required(ATTR_STATE): cv.string,
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up a control panel managed through IFTTT."""
|
||||
if DATA_IFTTT_ALARM not in hass.data:
|
||||
hass.data[DATA_IFTTT_ALARM] = []
|
||||
|
||||
name = config.get(CONF_NAME)
|
||||
code = config.get(CONF_CODE)
|
||||
event_away = config.get(CONF_EVENT_AWAY)
|
||||
event_home = config.get(CONF_EVENT_HOME)
|
||||
event_night = config.get(CONF_EVENT_NIGHT)
|
||||
event_disarm = config.get(CONF_EVENT_DISARM)
|
||||
optimistic = config.get(CONF_OPTIMISTIC)
|
||||
|
||||
alarmpanel = IFTTTAlarmPanel(name, code, event_away, event_home,
|
||||
event_night, event_disarm, optimistic)
|
||||
hass.data[DATA_IFTTT_ALARM].append(alarmpanel)
|
||||
add_devices([alarmpanel])
|
||||
|
||||
async def push_state_update(service):
|
||||
"""Set the service state as device state attribute."""
|
||||
entity_ids = service.data.get(ATTR_ENTITY_ID)
|
||||
state = service.data.get(ATTR_STATE)
|
||||
devices = hass.data[DATA_IFTTT_ALARM]
|
||||
if entity_ids:
|
||||
devices = [d for d in devices if d.entity_id in entity_ids]
|
||||
|
||||
for device in devices:
|
||||
device.push_alarm_state(state)
|
||||
device.async_schedule_update_ha_state()
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_PUSH_ALARM_STATE, push_state_update,
|
||||
schema=PUSH_ALARM_STATE_SERVICE_SCHEMA)
|
||||
|
||||
|
||||
class IFTTTAlarmPanel(alarm.AlarmControlPanel):
|
||||
"""Representation of an alarm control panel controlled through IFTTT."""
|
||||
|
||||
def __init__(self, name, code, event_away, event_home, event_night,
|
||||
event_disarm, optimistic):
|
||||
"""Initialize the alarm control panel."""
|
||||
self._name = name
|
||||
self._code = code
|
||||
self._event_away = event_away
|
||||
self._event_home = event_home
|
||||
self._event_night = event_night
|
||||
self._event_disarm = event_disarm
|
||||
self._optimistic = optimistic
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Notify that this platform return an assumed state."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
elif isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
if not self._check_code(code):
|
||||
return
|
||||
self.set_alarm_state(self._event_disarm, STATE_ALARM_DISARMED)
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
if not self._check_code(code):
|
||||
return
|
||||
self.set_alarm_state(self._event_away, STATE_ALARM_ARMED_AWAY)
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
if not self._check_code(code):
|
||||
return
|
||||
self.set_alarm_state(self._event_home, STATE_ALARM_ARMED_HOME)
|
||||
|
||||
def alarm_arm_night(self, code=None):
|
||||
"""Send arm night command."""
|
||||
if not self._check_code(code):
|
||||
return
|
||||
self.set_alarm_state(self._event_night, STATE_ALARM_ARMED_NIGHT)
|
||||
|
||||
def set_alarm_state(self, event, state):
|
||||
"""Call the IFTTT trigger service to change the alarm state."""
|
||||
data = {ATTR_EVENT: event}
|
||||
|
||||
self.hass.services.call(IFTTT_DOMAIN, SERVICE_TRIGGER, data)
|
||||
_LOGGER.debug("Called IFTTT component to trigger event %s", event)
|
||||
if self._optimistic:
|
||||
self._state = state
|
||||
|
||||
def push_alarm_state(self, value):
|
||||
"""Push the alarm state to the given value."""
|
||||
if value in ALLOWED_STATES:
|
||||
_LOGGER.debug("Pushed the alarm state to %s", value)
|
||||
self._state = value
|
||||
|
||||
def _check_code(self, code):
|
||||
return self._code is None or self._code == code
|
@ -7,6 +7,7 @@ https://home-assistant.io/components/alarm_control_panel.manual/
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -201,8 +202,12 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
elif isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
|
@ -8,6 +8,7 @@ import asyncio
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -237,8 +238,12 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
elif isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
|
@ -6,6 +6,7 @@ https://home-assistant.io/components/alarm_control_panel.mqtt/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -117,8 +118,12 @@ class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""One or more characters if code is defined."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
elif isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_disarm(self, code=None):
|
||||
|
@ -69,8 +69,8 @@ class NX584Alarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return che characters if code is defined."""
|
||||
return '[0-9]{4}([0-9]{2})?'
|
||||
"""Return one or more digits/characters."""
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
@ -66,7 +66,7 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanel):
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the regex for code format or None if no code is required."""
|
||||
return '^\\d{4,6}$'
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
@ -69,3 +69,13 @@ alarmdecoder_alarm_toggle_chime:
|
||||
code:
|
||||
description: A required code to toggle the alarm control panel chime with.
|
||||
example: 1234
|
||||
|
||||
ifttt_push_alarm_state:
|
||||
description: Update the alarm state to the specified value.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the alarm control panel which state has to be updated.
|
||||
example: 'alarm_control_panel.downstairs'
|
||||
state:
|
||||
description: The state to which the alarm control panel has to be set.
|
||||
example: 'armed_night'
|
||||
|
@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.simplisafe/
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -83,8 +84,12 @@ class SimpliSafeAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return one or more characters if code is defined."""
|
||||
return None if self._code is None else '.+'
|
||||
"""Return one or more digits/characters."""
|
||||
if self._code is None:
|
||||
return None
|
||||
elif isinstance(self._code, str) and re.search('^\\d+$', self._code):
|
||||
return 'Number'
|
||||
return 'Any'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||
|
||||
|
||||
REQUIREMENTS = ['total_connect_client==0.16']
|
||||
REQUIREMENTS = ['total_connect_client==0.18']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -60,8 +60,8 @@ class VerisureAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the code format as regex."""
|
||||
return '^\\d{%s}$' % self._digits
|
||||
"""Return one or more digits/characters."""
|
||||
return 'Number'
|
||||
|
||||
@property
|
||||
def changed_by(self):
|
||||
|
@ -6,18 +6,20 @@ from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from homeassistant.components import (
|
||||
alert, automation, cover, fan, group, input_boolean, light, lock,
|
||||
alert, automation, cover, climate, fan, group, input_boolean, light, lock,
|
||||
media_player, scene, script, switch, http, sensor)
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.util.temperature import convert as convert_temperature
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_NAME, SERVICE_LOCK,
|
||||
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, CONF_NAME,
|
||||
SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
|
||||
SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
SERVICE_UNLOCK, SERVICE_VOLUME_SET, TEMP_FAHRENHEIT, TEMP_CELSIUS,
|
||||
CONF_UNIT_OF_MEASUREMENT, STATE_LOCKED, STATE_UNLOCKED, STATE_ON)
|
||||
|
||||
from .const import CONF_FILTER, CONF_ENTITY_CONFIG
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -34,6 +36,16 @@ API_TEMP_UNITS = {
|
||||
TEMP_CELSIUS: 'CELSIUS',
|
||||
}
|
||||
|
||||
API_THERMOSTAT_MODES = {
|
||||
climate.STATE_HEAT: 'HEAT',
|
||||
climate.STATE_COOL: 'COOL',
|
||||
climate.STATE_AUTO: 'AUTO',
|
||||
climate.STATE_ECO: 'ECO',
|
||||
climate.STATE_IDLE: 'OFF',
|
||||
climate.STATE_FAN_ONLY: 'OFF',
|
||||
climate.STATE_DRY: 'OFF',
|
||||
}
|
||||
|
||||
SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home'
|
||||
|
||||
CONF_DESCRIPTION = 'description'
|
||||
@ -383,8 +395,60 @@ class _AlexaTemperatureSensor(_AlexaInterface):
|
||||
raise _UnsupportedProperty(name)
|
||||
|
||||
unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
temp = self.entity.state
|
||||
if self.entity.domain == climate.DOMAIN:
|
||||
temp = self.entity.attributes.get(
|
||||
climate.ATTR_CURRENT_TEMPERATURE)
|
||||
return {
|
||||
'value': float(self.entity.state),
|
||||
'value': float(temp),
|
||||
'scale': API_TEMP_UNITS[unit],
|
||||
}
|
||||
|
||||
|
||||
class _AlexaThermostatController(_AlexaInterface):
|
||||
def name(self):
|
||||
return 'Alexa.ThermostatController'
|
||||
|
||||
def properties_supported(self):
|
||||
properties = []
|
||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
if supported & climate.SUPPORT_TARGET_TEMPERATURE:
|
||||
properties.append({'name': 'targetSetpoint'})
|
||||
if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW:
|
||||
properties.append({'name': 'lowerSetpoint'})
|
||||
if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH:
|
||||
properties.append({'name': 'upperSetpoint'})
|
||||
if supported & climate.SUPPORT_OPERATION_MODE:
|
||||
properties.append({'name': 'thermostatMode'})
|
||||
return properties
|
||||
|
||||
def properties_retrievable(self):
|
||||
return True
|
||||
|
||||
def get_property(self, name):
|
||||
if name == 'thermostatMode':
|
||||
ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE)
|
||||
mode = API_THERMOSTAT_MODES.get(ha_mode)
|
||||
if mode is None:
|
||||
_LOGGER.error("%s (%s) has unsupported %s value '%s'",
|
||||
self.entity.entity_id, type(self.entity),
|
||||
climate.ATTR_OPERATION_MODE, ha_mode)
|
||||
raise _UnsupportedProperty(name)
|
||||
return mode
|
||||
|
||||
unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
temp = None
|
||||
if name == 'targetSetpoint':
|
||||
temp = self.entity.attributes.get(ATTR_TEMPERATURE)
|
||||
elif name == 'lowerSetpoint':
|
||||
temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)
|
||||
elif name == 'upperSetpoint':
|
||||
temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)
|
||||
if temp is None:
|
||||
raise _UnsupportedProperty(name)
|
||||
|
||||
return {
|
||||
'value': float(temp),
|
||||
'scale': API_TEMP_UNITS[unit],
|
||||
}
|
||||
|
||||
@ -415,6 +479,16 @@ class _SwitchCapabilities(_AlexaEntity):
|
||||
return [_AlexaPowerController(self.entity)]
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(climate.DOMAIN)
|
||||
class _ClimateCapabilities(_AlexaEntity):
|
||||
def default_display_categories(self):
|
||||
return [_DisplayCategory.THERMOSTAT]
|
||||
|
||||
def interfaces(self):
|
||||
yield _AlexaThermostatController(self.entity)
|
||||
yield _AlexaTemperatureSensor(self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(cover.DOMAIN)
|
||||
class _CoverCapabilities(_AlexaEntity):
|
||||
def default_display_categories(self):
|
||||
@ -438,9 +512,7 @@ class _LightCapabilities(_AlexaEntity):
|
||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
if supported & light.SUPPORT_BRIGHTNESS:
|
||||
yield _AlexaBrightnessController(self.entity)
|
||||
if supported & light.SUPPORT_RGB_COLOR:
|
||||
yield _AlexaColorController(self.entity)
|
||||
if supported & light.SUPPORT_XY_COLOR:
|
||||
if supported & light.SUPPORT_COLOR:
|
||||
yield _AlexaColorController(self.entity)
|
||||
if supported & light.SUPPORT_COLOR_TEMP:
|
||||
yield _AlexaColorTemperatureController(self.entity)
|
||||
@ -684,17 +756,26 @@ def api_message(request,
|
||||
return response
|
||||
|
||||
|
||||
def api_error(request, error_type='INTERNAL_ERROR', error_message=""):
|
||||
def api_error(request,
|
||||
namespace='Alexa',
|
||||
error_type='INTERNAL_ERROR',
|
||||
error_message="",
|
||||
payload=None):
|
||||
"""Create a API formatted error response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
payload = {
|
||||
'type': error_type,
|
||||
'message': error_message,
|
||||
}
|
||||
payload = payload or {}
|
||||
payload['type'] = error_type
|
||||
payload['message'] = error_message
|
||||
|
||||
return api_message(request, name='ErrorResponse', payload=payload)
|
||||
_LOGGER.info("Request %s/%s error %s: %s",
|
||||
request[API_HEADER]['namespace'],
|
||||
request[API_HEADER]['name'],
|
||||
error_type, error_message)
|
||||
|
||||
return api_message(
|
||||
request, name='ErrorResponse', namespace=namespace, payload=payload)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Discovery', 'Discover'))
|
||||
@ -842,25 +923,16 @@ def async_api_adjust_brightness(hass, config, request, entity):
|
||||
@asyncio.coroutine
|
||||
def async_api_set_color(hass, config, request, entity):
|
||||
"""Process a set color request."""
|
||||
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
rgb = color_util.color_hsb_to_RGB(
|
||||
float(request[API_PAYLOAD]['color']['hue']),
|
||||
float(request[API_PAYLOAD]['color']['saturation']),
|
||||
float(request[API_PAYLOAD]['color']['brightness'])
|
||||
)
|
||||
|
||||
if supported & light.SUPPORT_RGB_COLOR > 0:
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_RGB_COLOR: rgb,
|
||||
}, blocking=False)
|
||||
else:
|
||||
xyz = color_util.color_RGB_to_xy(*rgb)
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_XY_COLOR: (xyz[0], xyz[1]),
|
||||
light.ATTR_BRIGHTNESS: xyz[2],
|
||||
}, blocking=False)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
@ -1115,7 +1187,6 @@ def async_api_select_input(hass, config, request, entity):
|
||||
else:
|
||||
msg = 'failed to map input {} to a media source on {}'.format(
|
||||
media_input, entity.entity_id)
|
||||
_LOGGER.error(msg)
|
||||
return api_error(
|
||||
request, error_type='INVALID_VALUE', error_message=msg)
|
||||
|
||||
@ -1287,6 +1358,150 @@ def async_api_previous(hass, config, request, entity):
|
||||
return api_message(request)
|
||||
|
||||
|
||||
def api_error_temp_range(request, temp, min_temp, max_temp, unit):
|
||||
"""Create temperature value out of range API error response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
temp_range = {
|
||||
'minimumValue': {
|
||||
'value': min_temp,
|
||||
'scale': API_TEMP_UNITS[unit],
|
||||
},
|
||||
'maximumValue': {
|
||||
'value': max_temp,
|
||||
'scale': API_TEMP_UNITS[unit],
|
||||
},
|
||||
}
|
||||
|
||||
msg = 'The requested temperature {} is out of range'.format(temp)
|
||||
return api_error(
|
||||
request,
|
||||
error_type='TEMPERATURE_VALUE_OUT_OF_RANGE',
|
||||
error_message=msg,
|
||||
payload={'validRange': temp_range},
|
||||
)
|
||||
|
||||
|
||||
def temperature_from_object(temp_obj, to_unit, interval=False):
|
||||
"""Get temperature from Temperature object in requested unit."""
|
||||
from_unit = TEMP_CELSIUS
|
||||
temp = float(temp_obj['value'])
|
||||
|
||||
if temp_obj['scale'] == 'FAHRENHEIT':
|
||||
from_unit = TEMP_FAHRENHEIT
|
||||
elif temp_obj['scale'] == 'KELVIN':
|
||||
# convert to Celsius if absolute temperature
|
||||
if not interval:
|
||||
temp -= 273.15
|
||||
|
||||
return convert_temperature(temp, from_unit, to_unit, interval)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature'))
|
||||
@extract_entity
|
||||
async def async_api_set_target_temp(hass, config, request, entity):
|
||||
"""Process a set target temperature request."""
|
||||
unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
||||
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
payload = request[API_PAYLOAD]
|
||||
if 'targetSetpoint' in payload:
|
||||
temp = temperature_from_object(
|
||||
payload['targetSetpoint'], unit)
|
||||
if temp < min_temp or temp > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp, min_temp, max_temp, unit)
|
||||
data[ATTR_TEMPERATURE] = temp
|
||||
if 'lowerSetpoint' in payload:
|
||||
temp_low = temperature_from_object(
|
||||
payload['lowerSetpoint'], unit)
|
||||
if temp_low < min_temp or temp_low > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp_low, min_temp, max_temp, unit)
|
||||
data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
|
||||
if 'upperSetpoint' in payload:
|
||||
temp_high = temperature_from_object(
|
||||
payload['upperSetpoint'], unit)
|
||||
if temp_high < min_temp or temp_high > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, temp_high, min_temp, max_temp, unit)
|
||||
data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature'))
|
||||
@extract_entity
|
||||
async def async_api_adjust_target_temp(hass, config, request, entity):
|
||||
"""Process an adjust target temperature request."""
|
||||
unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT]
|
||||
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
||||
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
||||
|
||||
temp_delta = temperature_from_object(
|
||||
request[API_PAYLOAD]['targetSetpointDelta'], unit, interval=True)
|
||||
target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta
|
||||
|
||||
if target_temp < min_temp or target_temp > max_temp:
|
||||
return api_error_temp_range(
|
||||
request, target_temp, min_temp, max_temp, unit)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
ATTR_TEMPERATURE: target_temp,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode'))
|
||||
@extract_entity
|
||||
async def async_api_set_thermostat_mode(hass, config, request, entity):
|
||||
"""Process a set thermostat mode request."""
|
||||
mode = request[API_PAYLOAD]['thermostatMode']
|
||||
mode = mode if isinstance(mode, str) else mode['value']
|
||||
|
||||
operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST)
|
||||
# Work around a pylint false positive due to
|
||||
# https://github.com/PyCQA/pylint/issues/1830
|
||||
# pylint: disable=stop-iteration-return
|
||||
ha_mode = next(
|
||||
(k for k, v in API_THERMOSTAT_MODES.items() if v == mode),
|
||||
None
|
||||
)
|
||||
if ha_mode not in operation_list:
|
||||
msg = 'The requested thermostat mode {} is not supported'.format(mode)
|
||||
return api_error(
|
||||
request,
|
||||
namespace='Alexa.ThermostatController',
|
||||
error_type='UNSUPPORTED_THERMOSTAT_MODE',
|
||||
error_message=msg
|
||||
)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
climate.ATTR_OPERATION_MODE: ha_mode,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_OPERATION_MODE, data,
|
||||
blocking=False)
|
||||
|
||||
return api_message(request)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa', 'ReportState'))
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
|
@ -10,14 +10,15 @@ from datetime import timedelta
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
from requests.exceptions import HTTPError, ConnectTimeout
|
||||
from requests.exceptions import ConnectionError as ConnectError
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_SENSORS, CONF_SCAN_INTERVAL, HTTP_BASIC_AUTHENTICATION)
|
||||
CONF_SENSORS, CONF_SWITCHES, CONF_SCAN_INTERVAL, HTTP_BASIC_AUTHENTICATION)
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['amcrest==1.2.1']
|
||||
REQUIREMENTS = ['amcrest==1.2.2']
|
||||
DEPENDENCIES = ['ffmpeg']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -63,6 +64,12 @@ SENSORS = {
|
||||
'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'],
|
||||
}
|
||||
|
||||
# Switch types are defined like: Name, icon
|
||||
SWITCHES = {
|
||||
'motion_detection': ['Motion Detection', 'mdi:run-fast'],
|
||||
'motion_recording': ['Motion Recording', 'mdi:record-rec']
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
@ -81,6 +88,8 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
cv.time_period,
|
||||
vol.Optional(CONF_SENSORS):
|
||||
vol.All(cv.ensure_list, [vol.In(SENSORS)]),
|
||||
vol.Optional(CONF_SWITCHES):
|
||||
vol.All(cv.ensure_list, [vol.In(SWITCHES)]),
|
||||
})])
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
@ -93,14 +102,15 @@ def setup(hass, config):
|
||||
amcrest_cams = config[DOMAIN]
|
||||
|
||||
for device in amcrest_cams:
|
||||
try:
|
||||
camera = AmcrestCamera(device.get(CONF_HOST),
|
||||
device.get(CONF_PORT),
|
||||
device.get(CONF_USERNAME),
|
||||
device.get(CONF_PASSWORD)).camera
|
||||
try:
|
||||
# pylint: disable=pointless-statement
|
||||
camera.current_time
|
||||
|
||||
except (ConnectTimeout, HTTPError) as ex:
|
||||
except (ConnectError, ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Amcrest camera: %s", str(ex))
|
||||
hass.components.persistent_notification.create(
|
||||
'Error: {}<br />'
|
||||
@ -108,12 +118,13 @@ def setup(hass, config):
|
||||
''.format(ex),
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID)
|
||||
return False
|
||||
continue
|
||||
|
||||
ffmpeg_arguments = device.get(CONF_FFMPEG_ARGUMENTS)
|
||||
name = device.get(CONF_NAME)
|
||||
resolution = RESOLUTION_LIST[device.get(CONF_RESOLUTION)]
|
||||
sensors = device.get(CONF_SENSORS)
|
||||
switches = device.get(CONF_SWITCHES)
|
||||
stream_source = STREAM_SOURCE_LIST[device.get(CONF_STREAM_SOURCE)]
|
||||
|
||||
username = device.get(CONF_USERNAME)
|
||||
@ -143,6 +154,13 @@ def setup(hass, config):
|
||||
CONF_SENSORS: sensors,
|
||||
}, config)
|
||||
|
||||
if switches:
|
||||
discovery.load_platform(
|
||||
hass, 'switch', DOMAIN, {
|
||||
CONF_NAME: name,
|
||||
CONF_SWITCHES: switches
|
||||
}, config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
Rest API for Home Assistant.
|
||||
|
||||
For more details about the RESTful API, please refer to the documentation at
|
||||
https://home-assistant.io/developers/api/
|
||||
https://developers.home-assistant.io/docs/en/external_api_rest.html
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@ -11,31 +11,34 @@ import logging
|
||||
from aiohttp import web
|
||||
import async_timeout
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.bootstrap import DATA_LOGGING
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED,
|
||||
HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND,
|
||||
MATCH_ALL, URL_API, URL_API_COMPONENTS,
|
||||
URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG,
|
||||
URL_API_EVENTS, URL_API_SERVICES,
|
||||
URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE,
|
||||
__version__)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.state import AsyncTrackStates
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST,
|
||||
HTTP_CREATED, HTTP_NOT_FOUND, MATCH_ALL, URL_API, URL_API_COMPONENTS,
|
||||
URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG, URL_API_EVENTS,
|
||||
URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM,
|
||||
URL_API_TEMPLATE, __version__)
|
||||
import homeassistant.core as ha
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
from homeassistant.helpers.state import AsyncTrackStates
|
||||
import homeassistant.remote as rem
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_BASE_URL = 'base_url'
|
||||
ATTR_LOCATION_NAME = 'location_name'
|
||||
ATTR_REQUIRES_API_PASSWORD = 'requires_api_password'
|
||||
ATTR_VERSION = 'version'
|
||||
|
||||
DOMAIN = 'api'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
STREAM_PING_PAYLOAD = "ping"
|
||||
STREAM_PING_PAYLOAD = 'ping'
|
||||
STREAM_PING_INTERVAL = 50 # seconds
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Register the API with the HTTP interface."""
|
||||
@ -52,9 +55,8 @@ def setup(hass, config):
|
||||
hass.http.register_view(APIComponentsView)
|
||||
hass.http.register_view(APITemplateView)
|
||||
|
||||
log_path = hass.data.get(DATA_LOGGING, None)
|
||||
if log_path:
|
||||
hass.http.register_static_path(URL_API_ERROR_LOG, log_path, False)
|
||||
if DATA_LOGGING in hass.data:
|
||||
hass.http.register_view(APIErrorLog)
|
||||
|
||||
return True
|
||||
|
||||
@ -63,22 +65,21 @@ class APIStatusView(HomeAssistantView):
|
||||
"""View to handle Status requests."""
|
||||
|
||||
url = URL_API
|
||||
name = "api:status"
|
||||
name = 'api:status'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Retrieve if API is running."""
|
||||
return self.json_message('API running.')
|
||||
return self.json_message("API running.")
|
||||
|
||||
|
||||
class APIEventStream(HomeAssistantView):
|
||||
"""View to handle EventStream requests."""
|
||||
|
||||
url = URL_API_STREAM
|
||||
name = "api:stream"
|
||||
name = 'api:stream'
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
async def get(self, request):
|
||||
"""Provide a streaming interface for the event bus."""
|
||||
# pylint: disable=no-self-use
|
||||
hass = request.app['hass']
|
||||
@ -89,8 +90,7 @@ class APIEventStream(HomeAssistantView):
|
||||
if restrict:
|
||||
restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP]
|
||||
|
||||
@asyncio.coroutine
|
||||
def forward_events(event):
|
||||
async def forward_events(event):
|
||||
"""Forward events to the open request."""
|
||||
if event.event_type == EVENT_TIME_CHANGED:
|
||||
return
|
||||
@ -98,56 +98,56 @@ class APIEventStream(HomeAssistantView):
|
||||
if restrict and event.event_type not in restrict:
|
||||
return
|
||||
|
||||
_LOGGER.debug('STREAM %s FORWARDING %s', id(stop_obj), event)
|
||||
_LOGGER.debug("STREAM %s FORWARDING %s", id(stop_obj), event)
|
||||
|
||||
if event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
data = stop_obj
|
||||
else:
|
||||
data = json.dumps(event, cls=rem.JSONEncoder)
|
||||
|
||||
yield from to_write.put(data)
|
||||
await to_write.put(data)
|
||||
|
||||
response = web.StreamResponse()
|
||||
response.content_type = 'text/event-stream'
|
||||
yield from response.prepare(request)
|
||||
await response.prepare(request)
|
||||
|
||||
unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events)
|
||||
|
||||
try:
|
||||
_LOGGER.debug('STREAM %s ATTACHED', id(stop_obj))
|
||||
_LOGGER.debug("STREAM %s ATTACHED", id(stop_obj))
|
||||
|
||||
# Fire off one message so browsers fire open event right away
|
||||
yield from to_write.put(STREAM_PING_PAYLOAD)
|
||||
await to_write.put(STREAM_PING_PAYLOAD)
|
||||
|
||||
while True:
|
||||
try:
|
||||
with async_timeout.timeout(STREAM_PING_INTERVAL,
|
||||
loop=hass.loop):
|
||||
payload = yield from to_write.get()
|
||||
payload = await to_write.get()
|
||||
|
||||
if payload is stop_obj:
|
||||
break
|
||||
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
_LOGGER.debug('STREAM %s WRITING %s', id(stop_obj),
|
||||
msg.strip())
|
||||
yield from response.write(msg.encode("UTF-8"))
|
||||
_LOGGER.debug(
|
||||
"STREAM %s WRITING %s", id(stop_obj), msg.strip())
|
||||
await response.write(msg.encode('UTF-8'))
|
||||
except asyncio.TimeoutError:
|
||||
yield from to_write.put(STREAM_PING_PAYLOAD)
|
||||
await to_write.put(STREAM_PING_PAYLOAD)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug('STREAM %s ABORT', id(stop_obj))
|
||||
_LOGGER.debug("STREAM %s ABORT", id(stop_obj))
|
||||
|
||||
finally:
|
||||
_LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj))
|
||||
_LOGGER.debug("STREAM %s RESPONSE CLOSED", id(stop_obj))
|
||||
unsub_stream()
|
||||
|
||||
|
||||
class APIConfigView(HomeAssistantView):
|
||||
"""View to handle Config requests."""
|
||||
"""View to handle Configuration requests."""
|
||||
|
||||
url = URL_API_CONFIG
|
||||
name = "api:config"
|
||||
name = 'api:config'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
@ -156,22 +156,22 @@ class APIConfigView(HomeAssistantView):
|
||||
|
||||
|
||||
class APIDiscoveryView(HomeAssistantView):
|
||||
"""View to provide discovery info."""
|
||||
"""View to provide Discovery information."""
|
||||
|
||||
requires_auth = False
|
||||
url = URL_API_DISCOVERY_INFO
|
||||
name = "api:discovery"
|
||||
name = 'api:discovery'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
"""Get discovery info."""
|
||||
"""Get discovery information."""
|
||||
hass = request.app['hass']
|
||||
needs_auth = hass.config.api.api_password is not None
|
||||
return self.json({
|
||||
'base_url': hass.config.api.base_url,
|
||||
'location_name': hass.config.location_name,
|
||||
'requires_api_password': needs_auth,
|
||||
'version': __version__
|
||||
ATTR_BASE_URL: hass.config.api.base_url,
|
||||
ATTR_LOCATION_NAME: hass.config.location_name,
|
||||
ATTR_REQUIRES_API_PASSWORD: needs_auth,
|
||||
ATTR_VERSION: __version__,
|
||||
})
|
||||
|
||||
|
||||
@ -190,8 +190,8 @@ class APIStatesView(HomeAssistantView):
|
||||
class APIEntityStateView(HomeAssistantView):
|
||||
"""View to handle EntityState requests."""
|
||||
|
||||
url = "/api/states/{entity_id}"
|
||||
name = "api:entity-state"
|
||||
url = '/api/states/{entity_id}'
|
||||
name = 'api:entity-state'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request, entity_id):
|
||||
@ -199,22 +199,21 @@ class APIEntityStateView(HomeAssistantView):
|
||||
state = request.app['hass'].states.get(entity_id)
|
||||
if state:
|
||||
return self.json(state)
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
return self.json_message("Entity not found.", HTTP_NOT_FOUND)
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request, entity_id):
|
||||
async def post(self, request, entity_id):
|
||||
"""Update state of entity."""
|
||||
hass = request.app['hass']
|
||||
try:
|
||||
data = yield from request.json()
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON specified',
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Invalid JSON specified.", HTTP_BAD_REQUEST)
|
||||
|
||||
new_state = data.get('state')
|
||||
|
||||
if new_state is None:
|
||||
return self.json_message('No state specified', HTTP_BAD_REQUEST)
|
||||
return self.json_message("No state specified.", HTTP_BAD_REQUEST)
|
||||
|
||||
attributes = data.get('attributes')
|
||||
force_update = data.get('force_update', False)
|
||||
@ -236,15 +235,15 @@ class APIEntityStateView(HomeAssistantView):
|
||||
def delete(self, request, entity_id):
|
||||
"""Remove entity."""
|
||||
if request.app['hass'].states.async_remove(entity_id):
|
||||
return self.json_message('Entity removed')
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
return self.json_message("Entity removed.")
|
||||
return self.json_message("Entity not found.", HTTP_NOT_FOUND)
|
||||
|
||||
|
||||
class APIEventListenersView(HomeAssistantView):
|
||||
"""View to handle EventListeners requests."""
|
||||
|
||||
url = URL_API_EVENTS
|
||||
name = "api:event-listeners"
|
||||
name = 'api:event-listeners'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
@ -256,21 +255,20 @@ class APIEventView(HomeAssistantView):
|
||||
"""View to handle Event requests."""
|
||||
|
||||
url = '/api/events/{event_type}'
|
||||
name = "api:event"
|
||||
name = 'api:event'
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request, event_type):
|
||||
async def post(self, request, event_type):
|
||||
"""Fire events."""
|
||||
body = yield from request.text()
|
||||
body = await request.text()
|
||||
try:
|
||||
event_data = json.loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message('Event data should be valid JSON',
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Event data should be valid JSON.", HTTP_BAD_REQUEST)
|
||||
|
||||
if event_data is not None and not isinstance(event_data, dict):
|
||||
return self.json_message('Event data should be a JSON object',
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Event data should be a JSON object", HTTP_BAD_REQUEST)
|
||||
|
||||
# Special case handling for event STATE_CHANGED
|
||||
# We will try to convert state dicts back to State objects
|
||||
@ -281,8 +279,8 @@ class APIEventView(HomeAssistantView):
|
||||
if state:
|
||||
event_data[key] = state
|
||||
|
||||
request.app['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))
|
||||
|
||||
@ -291,37 +289,35 @@ class APIServicesView(HomeAssistantView):
|
||||
"""View to handle Services requests."""
|
||||
|
||||
url = URL_API_SERVICES
|
||||
name = "api:services"
|
||||
name = 'api:services'
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
async def get(self, request):
|
||||
"""Get registered services."""
|
||||
services = yield from async_services_json(request.app['hass'])
|
||||
services = await async_services_json(request.app['hass'])
|
||||
return self.json(services)
|
||||
|
||||
|
||||
class APIDomainServicesView(HomeAssistantView):
|
||||
"""View to handle DomainServices requests."""
|
||||
|
||||
url = "/api/services/{domain}/{service}"
|
||||
name = "api:domain-services"
|
||||
url = '/api/services/{domain}/{service}'
|
||||
name = 'api:domain-services'
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request, domain, service):
|
||||
async def post(self, request, domain, service):
|
||||
"""Call a service.
|
||||
|
||||
Returns a list of changed states.
|
||||
"""
|
||||
hass = request.app['hass']
|
||||
body = yield from request.text()
|
||||
body = await request.text()
|
||||
try:
|
||||
data = json.loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message('Data should be valid JSON',
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Data should be valid JSON.", HTTP_BAD_REQUEST)
|
||||
|
||||
with AsyncTrackStates(hass) as changed_states:
|
||||
yield from hass.services.async_call(domain, service, data, True)
|
||||
await hass.services.async_call(domain, service, data, True)
|
||||
|
||||
return self.json(changed_states)
|
||||
|
||||
@ -330,7 +326,7 @@ class APIComponentsView(HomeAssistantView):
|
||||
"""View to handle Components requests."""
|
||||
|
||||
url = URL_API_COMPONENTS
|
||||
name = "api:components"
|
||||
name = 'api:components'
|
||||
|
||||
@ha.callback
|
||||
def get(self, request):
|
||||
@ -339,32 +335,41 @@ class APIComponentsView(HomeAssistantView):
|
||||
|
||||
|
||||
class APITemplateView(HomeAssistantView):
|
||||
"""View to handle requests."""
|
||||
"""View to handle Template requests."""
|
||||
|
||||
url = URL_API_TEMPLATE
|
||||
name = "api:template"
|
||||
name = 'api:template'
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
async def post(self, request):
|
||||
"""Render a template."""
|
||||
try:
|
||||
data = yield from request.json()
|
||||
data = await request.json()
|
||||
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),
|
||||
HTTP_BAD_REQUEST)
|
||||
return self.json_message(
|
||||
"Error rendering template: {}".format(ex), HTTP_BAD_REQUEST)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_services_json(hass):
|
||||
class APIErrorLog(HomeAssistantView):
|
||||
"""View to fetch the API error log."""
|
||||
|
||||
url = URL_API_ERROR_LOG
|
||||
name = 'api:error_log'
|
||||
|
||||
async def get(self, request):
|
||||
"""Retrieve API error log."""
|
||||
return web.FileResponse(request.app['hass'].data[DATA_LOGGING])
|
||||
|
||||
|
||||
async def async_services_json(hass):
|
||||
"""Generate services data to JSONify."""
|
||||
descriptions = yield from async_get_all_descriptions(hass)
|
||||
return [{"domain": key, "services": value}
|
||||
descriptions = await async_get_all_descriptions(hass)
|
||||
return [{'domain': key, 'services': value}
|
||||
for key, value in descriptions.items()]
|
||||
|
||||
|
||||
def async_events_json(hass):
|
||||
"""Generate event data to JSONify."""
|
||||
return [{"event": key, "listener_count": value}
|
||||
return [{'event': key, 'listener_count': value}
|
||||
for key, value in hass.bus.async_listeners().items()]
|
||||
|
@ -17,7 +17,7 @@ from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyatv==0.3.9']
|
||||
REQUIREMENTS = ['pyatv==0.3.10']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
351
homeassistant/components/auth/__init__.py
Normal file
351
homeassistant/components/auth/__init__.py
Normal file
@ -0,0 +1,351 @@
|
||||
"""Component to allow users to login and get tokens.
|
||||
|
||||
All requests will require passing in a valid client ID and secret via HTTP
|
||||
Basic Auth.
|
||||
|
||||
# GET /auth/providers
|
||||
|
||||
Return a list of auth providers. Example:
|
||||
|
||||
[
|
||||
{
|
||||
"name": "Local",
|
||||
"id": null,
|
||||
"type": "local_provider",
|
||||
}
|
||||
]
|
||||
|
||||
# POST /auth/login_flow
|
||||
|
||||
Create a login flow. Will return the first step of the flow.
|
||||
|
||||
Pass in parameter 'handler' to specify the auth provider to use. Auth providers
|
||||
are identified by type and id.
|
||||
|
||||
{
|
||||
"handler": ["local_provider", null]
|
||||
}
|
||||
|
||||
Return value will be a step in a data entry flow. See the docs for data entry
|
||||
flow for details.
|
||||
|
||||
{
|
||||
"data_schema": [
|
||||
{"name": "username", "type": "string"},
|
||||
{"name": "password", "type": "string"}
|
||||
],
|
||||
"errors": {},
|
||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||
"handler": ["insecure_example", null],
|
||||
"step_id": "init",
|
||||
"type": "form"
|
||||
}
|
||||
|
||||
# POST /auth/login_flow/{flow_id}
|
||||
|
||||
Progress the flow. Most flows will be 1 page, but could optionally add extra
|
||||
login challenges, like TFA. Once the flow has finished, the returned step will
|
||||
have type "create_entry" and "result" key will contain an authorization code.
|
||||
|
||||
{
|
||||
"flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
|
||||
"handler": ["insecure_example", null],
|
||||
"result": "411ee2f916e648d691e937ae9344681e",
|
||||
"source": "user",
|
||||
"title": "Example",
|
||||
"type": "create_entry",
|
||||
"version": 1
|
||||
}
|
||||
|
||||
# POST /auth/token
|
||||
|
||||
This is an OAuth2 endpoint for granting tokens. We currently support the grant
|
||||
types "authorization_code" and "refresh_token". Because we follow the OAuth2
|
||||
spec, data should be send in formatted as x-www-form-urlencoded. Examples will
|
||||
be in JSON as it's more readable.
|
||||
|
||||
## Grant type authorization_code
|
||||
|
||||
Exchange the authorization code retrieved from the login flow for tokens.
|
||||
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "411ee2f916e648d691e937ae9344681e"
|
||||
}
|
||||
|
||||
Return value will be the access and refresh tokens. The access token will have
|
||||
a limited expiration. New access tokens can be requested using the refresh
|
||||
token.
|
||||
|
||||
{
|
||||
"access_token": "ABCDEFGH",
|
||||
"expires_in": 1800,
|
||||
"refresh_token": "IJKLMNOPQRST",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
|
||||
## Grant type refresh_token
|
||||
|
||||
Request a new access token using a refresh token.
|
||||
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "IJKLMNOPQRST"
|
||||
}
|
||||
|
||||
Return value will be a new access token. The access token will have
|
||||
a limited expiration.
|
||||
|
||||
{
|
||||
"access_token": "ABCDEFGH",
|
||||
"expires_in": 1800,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import aiohttp.web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.data_entry_flow import (
|
||||
FlowManagerIndexView, FlowManagerResourceView)
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
|
||||
from .client import verify_client
|
||||
|
||||
DOMAIN = 'auth'
|
||||
DEPENDENCIES = ['http']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Component to allow users to login."""
|
||||
store_credentials, retrieve_credentials = _create_cred_store()
|
||||
|
||||
hass.http.register_view(AuthProvidersView)
|
||||
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow))
|
||||
hass.http.register_view(
|
||||
LoginFlowResourceView(hass.auth.login_flow, store_credentials))
|
||||
hass.http.register_view(GrantTokenView(retrieve_credentials))
|
||||
hass.http.register_view(LinkUserView(retrieve_credentials))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AuthProvidersView(HomeAssistantView):
|
||||
"""View to get available auth providers."""
|
||||
|
||||
url = '/auth/providers'
|
||||
name = 'api:auth:providers'
|
||||
requires_auth = False
|
||||
|
||||
@verify_client
|
||||
async def get(self, request, client):
|
||||
"""Get available auth providers."""
|
||||
return self.json([{
|
||||
'name': provider.name,
|
||||
'id': provider.id,
|
||||
'type': provider.type,
|
||||
} for provider in request.app['hass'].auth.async_auth_providers])
|
||||
|
||||
|
||||
class LoginFlowIndexView(FlowManagerIndexView):
|
||||
"""View to create a config flow."""
|
||||
|
||||
url = '/auth/login_flow'
|
||||
name = 'api:auth:login_flow'
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request):
|
||||
"""Do not allow index of flows in progress."""
|
||||
return aiohttp.web.Response(status=405)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
@verify_client
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('handler'): vol.Any(str, list),
|
||||
vol.Required('redirect_uri'): str,
|
||||
}))
|
||||
async def post(self, request, client, data):
|
||||
"""Create a new login flow."""
|
||||
if data['redirect_uri'] not in client.redirect_uris:
|
||||
return self.json_message('invalid redirect uri', )
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
return await super().post(request)
|
||||
|
||||
|
||||
class LoginFlowResourceView(FlowManagerResourceView):
|
||||
"""View to interact with the flow manager."""
|
||||
|
||||
url = '/auth/login_flow/{flow_id}'
|
||||
name = 'api:auth:login_flow:resource'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, flow_mgr, store_credentials):
|
||||
"""Initialize the login flow resource view."""
|
||||
super().__init__(flow_mgr)
|
||||
self._store_credentials = store_credentials
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
async def get(self, request):
|
||||
"""Do not allow getting status of a flow in progress."""
|
||||
return self.json_message('Invalid flow specified', 404)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
@verify_client
|
||||
@RequestDataValidator(vol.Schema(dict), allow_empty=True)
|
||||
async def post(self, request, client, flow_id, data):
|
||||
"""Handle progressing a login flow request."""
|
||||
try:
|
||||
result = await self._flow_mgr.async_configure(flow_id, data)
|
||||
except data_entry_flow.UnknownFlow:
|
||||
return self.json_message('Invalid flow specified', 404)
|
||||
except vol.Invalid:
|
||||
return self.json_message('User input malformed', 400)
|
||||
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
return self.json(self._prepare_result_json(result))
|
||||
|
||||
result.pop('data')
|
||||
result['result'] = self._store_credentials(client.id, result['result'])
|
||||
|
||||
return self.json(result)
|
||||
|
||||
|
||||
class GrantTokenView(HomeAssistantView):
|
||||
"""View to grant tokens."""
|
||||
|
||||
url = '/auth/token'
|
||||
name = 'api:auth:token'
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, retrieve_credentials):
|
||||
"""Initialize the grant token view."""
|
||||
self._retrieve_credentials = retrieve_credentials
|
||||
|
||||
@verify_client
|
||||
async def post(self, request, client):
|
||||
"""Grant a token."""
|
||||
hass = request.app['hass']
|
||||
data = await request.post()
|
||||
grant_type = data.get('grant_type')
|
||||
|
||||
if grant_type == 'authorization_code':
|
||||
return await self._async_handle_auth_code(
|
||||
hass, client.id, data)
|
||||
|
||||
elif grant_type == 'refresh_token':
|
||||
return await self._async_handle_refresh_token(
|
||||
hass, client.id, data)
|
||||
|
||||
return self.json({
|
||||
'error': 'unsupported_grant_type',
|
||||
}, status_code=400)
|
||||
|
||||
async def _async_handle_auth_code(self, hass, client_id, data):
|
||||
"""Handle authorization code request."""
|
||||
code = data.get('code')
|
||||
|
||||
if code is None:
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
}, status_code=400)
|
||||
|
||||
credentials = self._retrieve_credentials(client_id, code)
|
||||
|
||||
if credentials is None:
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
}, status_code=400)
|
||||
|
||||
user = await hass.auth.async_get_or_create_user(credentials)
|
||||
refresh_token = await hass.auth.async_create_refresh_token(user,
|
||||
client_id)
|
||||
access_token = hass.auth.async_create_access_token(refresh_token)
|
||||
|
||||
return self.json({
|
||||
'access_token': access_token.token,
|
||||
'token_type': 'Bearer',
|
||||
'refresh_token': refresh_token.token,
|
||||
'expires_in':
|
||||
int(refresh_token.access_token_expiration.total_seconds()),
|
||||
})
|
||||
|
||||
async def _async_handle_refresh_token(self, hass, client_id, data):
|
||||
"""Handle authorization code request."""
|
||||
token = data.get('refresh_token')
|
||||
|
||||
if token is None:
|
||||
return self.json({
|
||||
'error': 'invalid_request',
|
||||
}, status_code=400)
|
||||
|
||||
refresh_token = await hass.auth.async_get_refresh_token(token)
|
||||
|
||||
if refresh_token is None or refresh_token.client_id != client_id:
|
||||
return self.json({
|
||||
'error': 'invalid_grant',
|
||||
}, status_code=400)
|
||||
|
||||
access_token = hass.auth.async_create_access_token(refresh_token)
|
||||
|
||||
return self.json({
|
||||
'access_token': access_token.token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in':
|
||||
int(refresh_token.access_token_expiration.total_seconds()),
|
||||
})
|
||||
|
||||
|
||||
class LinkUserView(HomeAssistantView):
|
||||
"""View to link existing users to new credentials."""
|
||||
|
||||
url = '/auth/link_user'
|
||||
name = 'api:auth:link_user'
|
||||
|
||||
def __init__(self, retrieve_credentials):
|
||||
"""Initialize the link user view."""
|
||||
self._retrieve_credentials = retrieve_credentials
|
||||
|
||||
@RequestDataValidator(vol.Schema({
|
||||
'code': str,
|
||||
'client_id': str,
|
||||
}))
|
||||
async def post(self, request, data):
|
||||
"""Link a user."""
|
||||
hass = request.app['hass']
|
||||
user = request['hass_user']
|
||||
|
||||
credentials = self._retrieve_credentials(
|
||||
data['client_id'], data['code'])
|
||||
|
||||
if credentials is None:
|
||||
return self.json_message('Invalid code', status_code=400)
|
||||
|
||||
await hass.auth.async_link_user(user, credentials)
|
||||
return self.json_message('User linked')
|
||||
|
||||
|
||||
@callback
|
||||
def _create_cred_store():
|
||||
"""Create a credential store."""
|
||||
temp_credentials = {}
|
||||
|
||||
@callback
|
||||
def store_credentials(client_id, credentials):
|
||||
"""Store credentials and return a code to retrieve it."""
|
||||
code = uuid.uuid4().hex
|
||||
temp_credentials[(client_id, code)] = credentials
|
||||
return code
|
||||
|
||||
@callback
|
||||
def retrieve_credentials(client_id, code):
|
||||
"""Retrieve credentials."""
|
||||
return temp_credentials.pop((client_id, code), None)
|
||||
|
||||
return store_credentials, retrieve_credentials
|
79
homeassistant/components/auth/client.py
Normal file
79
homeassistant/components/auth/client.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""Helpers to resolve client ID/secret."""
|
||||
import base64
|
||||
from functools import wraps
|
||||
import hmac
|
||||
|
||||
import aiohttp.hdrs
|
||||
|
||||
|
||||
def verify_client(method):
|
||||
"""Decorator to verify client id/secret on requests."""
|
||||
@wraps(method)
|
||||
async def wrapper(view, request, *args, **kwargs):
|
||||
"""Verify client id/secret before doing request."""
|
||||
client = await _verify_client(request)
|
||||
|
||||
if client is None:
|
||||
return view.json({
|
||||
'error': 'invalid_client',
|
||||
}, status_code=401)
|
||||
|
||||
return await method(
|
||||
view, request, *args, **kwargs, client=client)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def _verify_client(request):
|
||||
"""Method to verify the client id/secret in consistent time.
|
||||
|
||||
By using a consistent time for looking up client id and comparing the
|
||||
secret, we prevent attacks by malicious actors trying different client ids
|
||||
and are able to derive from the time it takes to process the request if
|
||||
they guessed the client id correctly.
|
||||
"""
|
||||
if aiohttp.hdrs.AUTHORIZATION not in request.headers:
|
||||
return None
|
||||
|
||||
auth_type, auth_value = \
|
||||
request.headers.get(aiohttp.hdrs.AUTHORIZATION).split(' ', 1)
|
||||
|
||||
if auth_type != 'Basic':
|
||||
return None
|
||||
|
||||
decoded = base64.b64decode(auth_value).decode('utf-8')
|
||||
try:
|
||||
client_id, client_secret = decoded.split(':', 1)
|
||||
except ValueError:
|
||||
# If no ':' in decoded
|
||||
client_id, client_secret = decoded, None
|
||||
|
||||
return await async_secure_get_client(
|
||||
request.app['hass'], client_id, client_secret)
|
||||
|
||||
|
||||
async def async_secure_get_client(hass, client_id, client_secret):
|
||||
"""Get a client id/secret in consistent time."""
|
||||
client = await hass.auth.async_get_client(client_id)
|
||||
|
||||
if client is None:
|
||||
if client_secret is not None:
|
||||
# Still do a compare so we run same time as if a client was found.
|
||||
hmac.compare_digest(client_secret.encode('utf-8'),
|
||||
client_secret.encode('utf-8'))
|
||||
return None
|
||||
|
||||
if client.secret is None:
|
||||
return client
|
||||
|
||||
elif client_secret is None:
|
||||
# Still do a compare so we run same time as if a secret was passed.
|
||||
hmac.compare_digest(client.secret.encode('utf-8'),
|
||||
client.secret.encode('utf-8'))
|
||||
return None
|
||||
|
||||
elif hmac.compare_digest(client_secret.encode('utf-8'),
|
||||
client.secret.encode('utf-8')):
|
||||
return client
|
||||
|
||||
return None
|
@ -6,6 +6,7 @@ https://home-assistant.io/components/automation/
|
||||
"""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
@ -22,7 +23,6 @@ from homeassistant.helpers import extract_domain_configs, script, condition
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import async_get_last_state
|
||||
from homeassistant.loader import get_platform
|
||||
from homeassistant.util.dt import utcnow
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
@ -58,12 +58,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def _platform_validator(config):
|
||||
"""Validate it is a valid platform."""
|
||||
platform = get_platform(DOMAIN, config[CONF_PLATFORM])
|
||||
try:
|
||||
platform = importlib.import_module(
|
||||
'homeassistant.components.automation.{}'.format(
|
||||
config[CONF_PLATFORM]))
|
||||
except ImportError:
|
||||
raise vol.Invalid('Invalid platform specified') from None
|
||||
|
||||
if not hasattr(platform, 'TRIGGER_SCHEMA'):
|
||||
return config
|
||||
|
||||
return getattr(platform, 'TRIGGER_SCHEMA')(config)
|
||||
return platform.TRIGGER_SCHEMA(config)
|
||||
|
||||
|
||||
_TRIGGER_SCHEMA = vol.All(
|
||||
@ -71,7 +73,7 @@ _TRIGGER_SCHEMA = vol.All(
|
||||
[
|
||||
vol.All(
|
||||
vol.Schema({
|
||||
vol.Required(CONF_PLATFORM): cv.platform_validator(DOMAIN)
|
||||
vol.Required(CONF_PLATFORM): str
|
||||
}, extra=vol.ALLOW_EXTRA),
|
||||
_platform_validator
|
||||
),
|
||||
@ -96,7 +98,7 @@ SERVICE_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
TRIGGER_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(ATTR_VARIABLES, default={}): dict,
|
||||
})
|
||||
|
||||
|
@ -50,13 +50,23 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES))
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Track states and offer events for binary sensors."""
|
||||
component = EntityComponent(
|
||||
component = hass.data[DOMAIN] = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
await component.async_setup(config)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Setup a config entry."""
|
||||
return await hass.data[DOMAIN].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DOMAIN].async_unload_entry(entry)
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
class BinarySensorDevice(Entity):
|
||||
"""Represent a binary sensor."""
|
||||
|
@ -217,4 +217,4 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
"""Get the latest data and update the states."""
|
||||
self._deviation = bool(self.probability > self._probability_threshold)
|
||||
self._deviation = bool(self.probability >= self._probability_threshold)
|
||||
|
@ -11,7 +11,6 @@ import voluptuous as vol
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
from homeassistant.loader import get_component
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -31,7 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the available BloomSky weather binary sensors."""
|
||||
bloomsky = get_component('bloomsky')
|
||||
bloomsky = hass.components.bloomsky
|
||||
# Default needed in case of discovery
|
||||
sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES)
|
||||
|
||||
|
196
homeassistant/components/binary_sensor/bmw_connected_drive.py
Normal file
196
homeassistant/components/binary_sensor/bmw_connected_drive.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""
|
||||
Reads vehicle status from BMW connected drive portal.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.bmw_connected_drive/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN
|
||||
|
||||
DEPENDENCIES = ['bmw_connected_drive']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_TYPES = {
|
||||
'lids': ['Doors', 'opening'],
|
||||
'windows': ['Windows', 'opening'],
|
||||
'door_lock_state': ['Door lock state', 'safety'],
|
||||
'lights_parking': ['Parking lights', 'light'],
|
||||
'condition_based_services': ['Condition based services', 'problem'],
|
||||
'check_control_messages': ['Control messages', 'problem']
|
||||
}
|
||||
|
||||
SENSOR_TYPES_ELEC = {
|
||||
'charging_status': ['Charging status', 'power'],
|
||||
'connection_status': ['Connection status', 'plug']
|
||||
}
|
||||
|
||||
SENSOR_TYPES_ELEC.update(SENSOR_TYPES)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the BMW sensors."""
|
||||
accounts = hass.data[BMW_DOMAIN]
|
||||
_LOGGER.debug('Found BMW accounts: %s',
|
||||
', '.join([a.name for a in accounts]))
|
||||
devices = []
|
||||
for account in accounts:
|
||||
for vehicle in account.account.vehicles:
|
||||
if vehicle.has_hv_battery:
|
||||
_LOGGER.debug('BMW with a high voltage battery')
|
||||
for key, value in sorted(SENSOR_TYPES_ELEC.items()):
|
||||
device = BMWConnectedDriveSensor(account, vehicle, key,
|
||||
value[0], value[1])
|
||||
devices.append(device)
|
||||
elif vehicle.has_internal_combustion_engine:
|
||||
_LOGGER.debug('BMW with an internal combustion engine')
|
||||
for key, value in sorted(SENSOR_TYPES.items()):
|
||||
device = BMWConnectedDriveSensor(account, vehicle, key,
|
||||
value[0], value[1])
|
||||
devices.append(device)
|
||||
add_devices(devices, True)
|
||||
|
||||
|
||||
class BMWConnectedDriveSensor(BinarySensorDevice):
|
||||
"""Representation of a BMW vehicle binary sensor."""
|
||||
|
||||
def __init__(self, account, vehicle, attribute: str, sensor_name,
|
||||
device_class):
|
||||
"""Constructor."""
|
||||
self._account = account
|
||||
self._vehicle = vehicle
|
||||
self._attribute = attribute
|
||||
self._name = '{} {}'.format(self._vehicle.name, self._attribute)
|
||||
self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute)
|
||||
self._sensor_name = sensor_name
|
||||
self._device_class = device_class
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Data update is triggered from BMWConnectedDriveEntity."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique ID of the binary sensor."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the binary sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of the binary sensor."""
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the state of the binary sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes of the binary sensor."""
|
||||
vehicle_state = self._vehicle.state
|
||||
result = {
|
||||
'car': self._vehicle.name
|
||||
}
|
||||
|
||||
if self._attribute == 'lids':
|
||||
for lid in vehicle_state.lids:
|
||||
result[lid.name] = lid.state.value
|
||||
elif self._attribute == 'windows':
|
||||
for window in vehicle_state.windows:
|
||||
result[window.name] = window.state.value
|
||||
elif self._attribute == 'door_lock_state':
|
||||
result['door_lock_state'] = vehicle_state.door_lock_state.value
|
||||
result['last_update_reason'] = vehicle_state.last_update_reason
|
||||
elif self._attribute == 'lights_parking':
|
||||
result['lights_parking'] = vehicle_state.parking_lights.value
|
||||
elif self._attribute == 'condition_based_services':
|
||||
for report in vehicle_state.condition_based_services:
|
||||
result.update(self._format_cbs_report(report))
|
||||
elif self._attribute == 'check_control_messages':
|
||||
check_control_messages = vehicle_state.check_control_messages
|
||||
if not check_control_messages:
|
||||
result['check_control_messages'] = 'OK'
|
||||
else:
|
||||
result['check_control_messages'] = check_control_messages
|
||||
elif self._attribute == 'charging_status':
|
||||
result['charging_status'] = vehicle_state.charging_status.value
|
||||
# pylint: disable=W0212
|
||||
result['last_charging_end_result'] = \
|
||||
vehicle_state._attributes['lastChargingEndResult']
|
||||
if self._attribute == 'connection_status':
|
||||
# pylint: disable=W0212
|
||||
result['connection_status'] = \
|
||||
vehicle_state._attributes['connectionStatus']
|
||||
|
||||
return sorted(result.items())
|
||||
|
||||
def update(self):
|
||||
"""Read new state data from the library."""
|
||||
from bimmer_connected.state import LockState
|
||||
from bimmer_connected.state import ChargingState
|
||||
vehicle_state = self._vehicle.state
|
||||
|
||||
# device class opening: On means open, Off means closed
|
||||
if self._attribute == 'lids':
|
||||
_LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed)
|
||||
self._state = not vehicle_state.all_lids_closed
|
||||
if self._attribute == 'windows':
|
||||
self._state = not vehicle_state.all_windows_closed
|
||||
# device class safety: On means unsafe, Off means safe
|
||||
if self._attribute == 'door_lock_state':
|
||||
# Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
|
||||
self._state = vehicle_state.door_lock_state not in \
|
||||
[LockState.LOCKED, LockState.SECURED]
|
||||
# device class light: On means light detected, Off means no light
|
||||
if self._attribute == 'lights_parking':
|
||||
self._state = vehicle_state.are_parking_lights_on
|
||||
# device class problem: On means problem detected, Off means no problem
|
||||
if self._attribute == 'condition_based_services':
|
||||
self._state = not vehicle_state.are_all_cbs_ok
|
||||
if self._attribute == 'check_control_messages':
|
||||
self._state = vehicle_state.has_check_control_messages
|
||||
# device class power: On means power detected, Off means no power
|
||||
if self._attribute == 'charging_status':
|
||||
self._state = vehicle_state.charging_status in \
|
||||
[ChargingState.CHARGING]
|
||||
# device class plug: On means device is plugged in,
|
||||
# Off means device is unplugged
|
||||
if self._attribute == 'connection_status':
|
||||
# pylint: disable=W0212
|
||||
self._state = (vehicle_state._attributes['connectionStatus'] ==
|
||||
'CONNECTED')
|
||||
|
||||
@staticmethod
|
||||
def _format_cbs_report(report):
|
||||
result = {}
|
||||
service_type = report.service_type.lower().replace('_', ' ')
|
||||
result['{} status'.format(service_type)] = report.state.value
|
||||
if report.due_date is not None:
|
||||
result['{} date'.format(service_type)] = \
|
||||
report.due_date.strftime('%Y-%m-%d')
|
||||
if report.due_distance is not None:
|
||||
result['{} distance'.format(service_type)] = \
|
||||
'{} km'.format(report.due_distance)
|
||||
return result
|
||||
|
||||
def update_callback(self):
|
||||
"""Schedule a state update."""
|
||||
self.schedule_update_ha_state(True)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Add callback after being added to hass.
|
||||
|
||||
Show latest data after startup.
|
||||
"""
|
||||
self._account.add_update_listener(self.update_callback)
|
@ -6,29 +6,40 @@ https://home-assistant.io/components/binary_sensor.deconz/
|
||||
"""
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.deconz import (
|
||||
DOMAIN as DATA_DECONZ, DATA_DECONZ_ID)
|
||||
CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID,
|
||||
DATA_DECONZ_UNSUB)
|
||||
from homeassistant.const import ATTR_BATTERY_LEVEL
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
DEPENDENCIES = ['deconz']
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Old way of setting up deCONZ binary sensors."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the deCONZ binary sensor."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_add_sensor(sensors):
|
||||
"""Add binary sensor from deCONZ."""
|
||||
from pydeconz.sensor import DECONZ_BINARY_SENSOR
|
||||
sensors = hass.data[DATA_DECONZ].sensors
|
||||
entities = []
|
||||
|
||||
for key in sorted(sensors.keys(), key=int):
|
||||
sensor = sensors[key]
|
||||
if sensor and sensor.type in DECONZ_BINARY_SENSOR:
|
||||
allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True)
|
||||
for sensor in sensors:
|
||||
if sensor.type in DECONZ_BINARY_SENSOR and \
|
||||
not (not allow_clip_sensor and sensor.type.startswith('CLIP')):
|
||||
entities.append(DeconzBinarySensor(sensor))
|
||||
async_add_devices(entities, True)
|
||||
|
||||
hass.data[DATA_DECONZ_UNSUB].append(
|
||||
async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor))
|
||||
|
||||
async_add_sensor(hass.data[DATA_DECONZ].sensors.values())
|
||||
|
||||
|
||||
class DeconzBinarySensor(BinarySensorDevice):
|
||||
"""Representation of a binary sensor."""
|
||||
@ -93,9 +104,9 @@ class DeconzBinarySensor(BinarySensorDevice):
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes of the sensor."""
|
||||
from pydeconz.sensor import PRESENCE
|
||||
attr = {
|
||||
ATTR_BATTERY_LEVEL: self._sensor.battery,
|
||||
}
|
||||
if self._sensor.type in PRESENCE:
|
||||
attr = {}
|
||||
if self._sensor.battery:
|
||||
attr[ATTR_BATTERY_LEVEL] = self._sensor.battery
|
||||
if self._sensor.type in PRESENCE and self._sensor.dark is not None:
|
||||
attr['dark'] = self._sensor.dark
|
||||
return attr
|
||||
|
@ -12,7 +12,7 @@ from homeassistant.const import STATE_ON, STATE_OFF
|
||||
from homeassistant.components.egardia import (
|
||||
EGARDIA_DEVICE, ATTR_DISCOVER_DEVICES)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['egardia']
|
||||
EGARDIA_TYPE_TO_DEVICE_CLASS = {'IR Sensor': 'motion',
|
||||
'Door Contact': 'opening',
|
||||
'IR': 'motion'}
|
||||
|
@ -6,6 +6,7 @@ https://home-assistant.io/components/binary_sensor.envisalink/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@ -14,6 +15,7 @@ from homeassistant.components.envisalink import (
|
||||
DATA_EVL, ZONE_SCHEMA, CONF_ZONENAME, CONF_ZONETYPE, EnvisalinkDevice,
|
||||
SIGNAL_ZONE_UPDATE)
|
||||
from homeassistant.const import ATTR_LAST_TRIP_TIME
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -63,7 +65,25 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice):
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attr = {}
|
||||
attr[ATTR_LAST_TRIP_TIME] = self._info['last_fault']
|
||||
|
||||
# The Envisalink library returns a "last_fault" value that's the
|
||||
# number of seconds since the last fault, up to a maximum of 327680
|
||||
# seconds (65536 5-second ticks).
|
||||
#
|
||||
# We don't want the HA event log to fill up with a bunch of no-op
|
||||
# "state changes" that are just that number ticking up once per poll
|
||||
# interval, so we subtract it from the current second-accurate time
|
||||
# unless it is already at the maximum value, in which case we set it
|
||||
# to None since we can't determine the actual value.
|
||||
seconds_ago = self._info['last_fault']
|
||||
if seconds_ago < 65536 * 5:
|
||||
now = dt_util.now().replace(microsecond=0)
|
||||
delta = datetime.timedelta(seconds=seconds_ago)
|
||||
last_trip_time = (now - delta).isoformat()
|
||||
else:
|
||||
last_trip_time = None
|
||||
|
||||
attr[ATTR_LAST_TRIP_TIME] = last_trip_time
|
||||
return attr
|
||||
|
||||
@property
|
||||
|
@ -32,6 +32,7 @@ class HiveBinarySensorEntity(BinarySensorDevice):
|
||||
self.device_type = hivedevice["HA_DeviceType"]
|
||||
self.node_device_type = hivedevice["Hive_DeviceType"]
|
||||
self.session = hivesession
|
||||
self.attributes = {}
|
||||
self.data_updatesource = '{}.{}'.format(self.device_type,
|
||||
self.node_id)
|
||||
|
||||
@ -52,6 +53,11 @@ class HiveBinarySensorEntity(BinarySensorDevice):
|
||||
"""Return the name of the binary sensor."""
|
||||
return self.node_name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Show Device Attributes."""
|
||||
return self.attributes
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
@ -61,3 +67,5 @@ class HiveBinarySensorEntity(BinarySensorDevice):
|
||||
def update(self):
|
||||
"""Update all Node data from Hive."""
|
||||
self.session.core.update_data(self.node_id)
|
||||
self.attributes = self.session.attributes.state_attributes(
|
||||
self.node_id)
|
||||
|
85
homeassistant/components/binary_sensor/homematicip_cloud.py
Normal file
85
homeassistant/components/binary_sensor/homematicip_cloud.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""
|
||||
Support for HomematicIP binary sensor.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.homematicip_cloud/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.homematicip_cloud import (
|
||||
HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN,
|
||||
ATTR_HOME_ID)
|
||||
|
||||
DEPENDENCIES = ['homematicip_cloud']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_WINDOW_STATE = 'window_state'
|
||||
ATTR_EVENT_DELAY = 'event_delay'
|
||||
ATTR_MOTION_DETECTED = 'motion_detected'
|
||||
ATTR_ILLUMINATION = 'illumination'
|
||||
|
||||
HMIP_OPEN = 'open'
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the HomematicIP binary sensor devices."""
|
||||
from homematicip.device import (ShutterContact, MotionDetectorIndoor)
|
||||
|
||||
if discovery_info is None:
|
||||
return
|
||||
home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]]
|
||||
devices = []
|
||||
for device in home.devices:
|
||||
if isinstance(device, ShutterContact):
|
||||
devices.append(HomematicipShutterContact(home, device))
|
||||
elif isinstance(device, MotionDetectorIndoor):
|
||||
devices.append(HomematicipMotionDetector(home, device))
|
||||
|
||||
if devices:
|
||||
async_add_devices(devices)
|
||||
|
||||
|
||||
class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice):
|
||||
"""HomematicIP shutter contact."""
|
||||
|
||||
def __init__(self, home, device):
|
||||
"""Initialize the shutter contact."""
|
||||
super().__init__(home, device)
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor."""
|
||||
return 'door'
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the shutter contact is on/open."""
|
||||
if self._device.sabotage:
|
||||
return True
|
||||
if self._device.windowState is None:
|
||||
return None
|
||||
return self._device.windowState.lower() == HMIP_OPEN
|
||||
|
||||
|
||||
class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice):
|
||||
"""MomematicIP motion detector."""
|
||||
|
||||
def __init__(self, home, device):
|
||||
"""Initialize the shutter contact."""
|
||||
super().__init__(home, device)
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor."""
|
||||
return 'motion'
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if motion is detected."""
|
||||
if self._device.sabotage:
|
||||
return True
|
||||
return self._device.motionDetected
|
81
homeassistant/components/binary_sensor/hydrawise.py
Normal file
81
homeassistant/components/binary_sensor/hydrawise.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""
|
||||
Support for Hydrawise sprinkler.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.hydrawise/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.hydrawise import (
|
||||
BINARY_SENSORS, DATA_HYDRAWISE, HydrawiseEntity, DEVICE_MAP,
|
||||
DEVICE_MAP_INDEX)
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
|
||||
DEPENDENCIES = ['hydrawise']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSORS):
|
||||
vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]),
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up a sensor for a Hydrawise device."""
|
||||
hydrawise = hass.data[DATA_HYDRAWISE].data
|
||||
|
||||
sensors = []
|
||||
for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
|
||||
if sensor_type in ['status', 'rain_sensor']:
|
||||
sensors.append(
|
||||
HydrawiseBinarySensor(
|
||||
hydrawise.controller_status, sensor_type))
|
||||
|
||||
else:
|
||||
# create a sensor for each zone
|
||||
for zone in hydrawise.relays:
|
||||
zone_data = zone
|
||||
zone_data['running'] = \
|
||||
hydrawise.controller_status.get('running', False)
|
||||
sensors.append(HydrawiseBinarySensor(zone_data, sensor_type))
|
||||
|
||||
add_devices(sensors, True)
|
||||
|
||||
|
||||
class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorDevice):
|
||||
"""A sensor implementation for Hydrawise device."""
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self._state
|
||||
|
||||
def update(self):
|
||||
"""Get the latest data and updates the state."""
|
||||
_LOGGER.debug("Updating Hydrawise binary sensor: %s", self._name)
|
||||
mydata = self.hass.data[DATA_HYDRAWISE].data
|
||||
if self._sensor_type == 'status':
|
||||
self._state = mydata.status == 'All good!'
|
||||
elif self._sensor_type == 'rain_sensor':
|
||||
for sensor in mydata.sensors:
|
||||
if sensor['name'] == 'Rain':
|
||||
self._state = sensor['active'] == 1
|
||||
elif self._sensor_type == 'is_watering':
|
||||
if not mydata.running:
|
||||
self._state = False
|
||||
elif int(mydata.running[0]['relay']) == self.data['relay']:
|
||||
self._state = True
|
||||
else:
|
||||
self._state = False
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class of the sensor type."""
|
||||
return DEVICE_MAP[self._sensor_type][
|
||||
DEVICE_MAP_INDEX.index('DEVICE_CLASS_INDEX')]
|
@ -17,18 +17,19 @@ _LOGGER = logging.getLogger(__name__)
|
||||
SENSOR_TYPES = {'openClosedSensor': 'opening',
|
||||
'motionSensor': 'motion',
|
||||
'doorSensor': 'door',
|
||||
'leakSensor': 'moisture'}
|
||||
'wetLeakSensor': 'moisture'}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the INSTEON PLM device class for the hass platform."""
|
||||
plm = hass.data['insteon_plm']
|
||||
plm = hass.data['insteon_plm'].get('plm')
|
||||
|
||||
address = discovery_info['address']
|
||||
device = plm.devices[address]
|
||||
state_key = discovery_info['state_key']
|
||||
|
||||
name = device.states[state_key].name
|
||||
if name != 'dryLeakSensor':
|
||||
_LOGGER.debug('Adding device %s entity %s to Binary Sensor platform',
|
||||
device.address.hex, device.states[state_key].name)
|
||||
|
||||
@ -53,5 +54,4 @@ class InsteonPLMBinarySensor(InsteonPLMEntity, BinarySensorDevice):
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the boolean response if the node is on."""
|
||||
sensorstate = self._insteon_device_state.value
|
||||
return bool(sensorstate)
|
||||
return bool(self._insteon_device_state.value)
|
||||
|
@ -117,8 +117,10 @@ class ISYBinarySensorDevice(ISYDevice, BinarySensorDevice):
|
||||
# pylint: disable=protected-access
|
||||
if _is_val_unknown(self._node.status._val):
|
||||
self._computed_state = None
|
||||
self._status_was_unknown = True
|
||||
else:
|
||||
self._computed_state = bool(self._node.status._val)
|
||||
self._status_was_unknown = False
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self) -> None:
|
||||
@ -156,7 +158,11 @@ class ISYBinarySensorDevice(ISYDevice, BinarySensorDevice):
|
||||
# pylint: disable=protected-access
|
||||
if not _is_val_unknown(self._negative_node.status._val):
|
||||
# If the negative node has a value, it means the negative node is
|
||||
# in use for this device. Therefore, we cannot determine the state
|
||||
# in use for this device. Next we need to check to see if the
|
||||
# negative and positive nodes disagree on the state (both ON or
|
||||
# both OFF).
|
||||
if self._negative_node.status._val == self._node.status._val:
|
||||
# The states disagree, therefore we cannot determine the state
|
||||
# of the sensor until we receive our first ON event.
|
||||
self._computed_state = None
|
||||
|
||||
@ -189,14 +195,21 @@ class ISYBinarySensorDevice(ISYDevice, BinarySensorDevice):
|
||||
self.schedule_update_ha_state()
|
||||
self._heartbeat()
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def on_update(self, event: object) -> None:
|
||||
"""Ignore primary node status updates.
|
||||
"""Primary node status updates.
|
||||
|
||||
We listen directly to the Control events on all nodes for this
|
||||
device.
|
||||
We MOSTLY ignore these updates, as we listen directly to the Control
|
||||
events on all nodes for this device. However, there is one edge case:
|
||||
If a leak sensor is unknown, due to a recent reboot of the ISY, the
|
||||
status will get updated to dry upon the first heartbeat. This status
|
||||
update is the only way that a leak sensor's status changes without
|
||||
an accompanying Control event, so we need to watch for it.
|
||||
"""
|
||||
pass
|
||||
if self._status_was_unknown and self._computed_state is None:
|
||||
self._computed_state = bool(int(self._node.status))
|
||||
self._status_was_unknown = False
|
||||
self.schedule_update_ha_state()
|
||||
self._heartbeat()
|
||||
|
||||
@property
|
||||
def value(self) -> object:
|
||||
|
82
homeassistant/components/binary_sensor/konnected.py
Normal file
82
homeassistant/components/binary_sensor/konnected.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""
|
||||
Support for wired binary sensors attached to a Konnected device.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.konnected/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.konnected import (
|
||||
DOMAIN as KONNECTED_DOMAIN, PIN_TO_ZONE, SIGNAL_SENSOR_UPDATE)
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICES, CONF_TYPE, CONF_NAME, CONF_BINARY_SENSORS, ATTR_ENTITY_ID,
|
||||
ATTR_STATE)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['konnected']
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up binary sensors attached to a Konnected device."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
data = hass.data[KONNECTED_DOMAIN]
|
||||
device_id = discovery_info['device_id']
|
||||
sensors = [KonnectedBinarySensor(device_id, pin_num, pin_data)
|
||||
for pin_num, pin_data in
|
||||
data[CONF_DEVICES][device_id][CONF_BINARY_SENSORS].items()]
|
||||
async_add_devices(sensors)
|
||||
|
||||
|
||||
class KonnectedBinarySensor(BinarySensorDevice):
|
||||
"""Representation of a Konnected binary sensor."""
|
||||
|
||||
def __init__(self, device_id, pin_num, data):
|
||||
"""Initialize the binary sensor."""
|
||||
self._data = data
|
||||
self._device_id = device_id
|
||||
self._pin_num = pin_num
|
||||
self._state = self._data.get(ATTR_STATE)
|
||||
self._device_class = self._data.get(CONF_TYPE)
|
||||
self._name = self._data.get(CONF_NAME, 'Konnected {} Zone {}'.format(
|
||||
device_id, PIN_TO_ZONE[pin_num]))
|
||||
_LOGGER.debug('Created new Konnected sensor: %s', self._name)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class."""
|
||||
return self._device_class
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Store entity_id and register state change callback."""
|
||||
self._data[ATTR_ENTITY_ID] = self.entity_id
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id),
|
||||
self.async_set_state)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the sensor's state."""
|
||||
self._state = state
|
||||
self.async_schedule_update_ha_state()
|
@ -7,7 +7,7 @@ https://home-assistant.io/components/maxcube/
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.maxcube import MAXCUBE_HANDLE
|
||||
from homeassistant.components.maxcube import DATA_KEY
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -15,16 +15,17 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Iterate through all MAX! Devices and add window shutters."""
|
||||
cube = hass.data[MAXCUBE_HANDLE].cube
|
||||
devices = []
|
||||
|
||||
for handler in hass.data[DATA_KEY].values():
|
||||
cube = handler.cube
|
||||
for device in cube.devices:
|
||||
name = "{} {}".format(
|
||||
cube.room_by_id(device.room_id).name, device.name)
|
||||
|
||||
# Only add Window Shutters
|
||||
if cube.is_windowshutter(device):
|
||||
devices.append(MaxCubeShutter(hass, name, device.rf_address))
|
||||
devices.append(
|
||||
MaxCubeShutter(handler, name, device.rf_address))
|
||||
|
||||
if devices:
|
||||
add_devices(devices)
|
||||
@ -33,12 +34,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
class MaxCubeShutter(BinarySensorDevice):
|
||||
"""Representation of a MAX! Cube Binary Sensor device."""
|
||||
|
||||
def __init__(self, hass, name, rf_address):
|
||||
def __init__(self, handler, name, rf_address):
|
||||
"""Initialize MAX! Cube BinarySensorDevice."""
|
||||
self._name = name
|
||||
self._sensor_type = 'window'
|
||||
self._rf_address = rf_address
|
||||
self._cubehandle = hass.data[MAXCUBE_HANDLE]
|
||||
self._cubehandle = handler
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
|
@ -1,97 +0,0 @@
|
||||
"""
|
||||
Support for Mercedes cars with Mercedes ME.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.mercedesme/
|
||||
"""
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
from homeassistant.components.binary_sensor import (BinarySensorDevice)
|
||||
from homeassistant.components.mercedesme import (
|
||||
DATA_MME, FEATURE_NOT_AVAILABLE, MercedesMeEntity, BINARY_SENSORS)
|
||||
|
||||
DEPENDENCIES = ['mercedesme']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the sensor platform."""
|
||||
data = hass.data[DATA_MME].data
|
||||
|
||||
if not data.cars:
|
||||
_LOGGER.error("No cars found. Check component log.")
|
||||
return
|
||||
|
||||
devices = []
|
||||
for car in data.cars:
|
||||
for key, value in sorted(BINARY_SENSORS.items()):
|
||||
if car['availabilities'].get(key, 'INVALID') == 'VALID':
|
||||
devices.append(MercedesMEBinarySensor(
|
||||
data, key, value[0], car["vin"], None))
|
||||
else:
|
||||
_LOGGER.warning(FEATURE_NOT_AVAILABLE, key, car["license"])
|
||||
|
||||
add_devices(devices, True)
|
||||
|
||||
|
||||
class MercedesMEBinarySensor(MercedesMeEntity, BinarySensorDevice):
|
||||
"""Representation of a Sensor."""
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the state of the binary sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
if self._internal_name == "windowsClosed":
|
||||
return {
|
||||
"window_front_left": self._car["windowStatusFrontLeft"],
|
||||
"window_front_right": self._car["windowStatusFrontRight"],
|
||||
"window_rear_left": self._car["windowStatusRearLeft"],
|
||||
"window_rear_right": self._car["windowStatusRearRight"],
|
||||
"original_value": self._car[self._internal_name],
|
||||
"last_update": datetime.datetime.fromtimestamp(
|
||||
self._car["lastUpdate"]).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"car": self._car["license"]
|
||||
}
|
||||
elif self._internal_name == "tireWarningLight":
|
||||
return {
|
||||
"front_right_tire_pressure_kpa":
|
||||
self._car["frontRightTirePressureKpa"],
|
||||
"front_left_tire_pressure_kpa":
|
||||
self._car["frontLeftTirePressureKpa"],
|
||||
"rear_right_tire_pressure_kpa":
|
||||
self._car["rearRightTirePressureKpa"],
|
||||
"rear_left_tire_pressure_kpa":
|
||||
self._car["rearLeftTirePressureKpa"],
|
||||
"original_value": self._car[self._internal_name],
|
||||
"last_update": datetime.datetime.fromtimestamp(
|
||||
self._car["lastUpdate"]
|
||||
).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"car": self._car["license"],
|
||||
}
|
||||
return {
|
||||
"original_value": self._car[self._internal_name],
|
||||
"last_update": datetime.datetime.fromtimestamp(
|
||||
self._car["lastUpdate"]).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"car": self._car["license"]
|
||||
}
|
||||
|
||||
def update(self):
|
||||
"""Fetch new state data for the sensor."""
|
||||
self._car = next(
|
||||
car for car in self._data.cars if car["vin"] == self._vin)
|
||||
|
||||
if self._internal_name == "windowsClosed":
|
||||
self._state = bool(self._car[self._internal_name] == "CLOSED")
|
||||
elif self._internal_name == "tireWarningLight":
|
||||
self._state = bool(self._car[self._internal_name] != "INACTIVE")
|
||||
else:
|
||||
self._state = self._car[self._internal_name] is True
|
||||
|
||||
_LOGGER.debug("Updated %s Value: %s IsOn: %s",
|
||||
self._internal_name, self._state, self.is_on)
|
@ -31,7 +31,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
sensors = []
|
||||
hub = hass.data[MYCHEVY_DOMAIN]
|
||||
for sconfig in SENSORS:
|
||||
sensors.append(EVBinarySensor(hub, sconfig))
|
||||
for car in hub.cars:
|
||||
sensors.append(EVBinarySensor(hub, sconfig, car.vid))
|
||||
|
||||
async_add_devices(sensors)
|
||||
|
||||
@ -45,16 +46,18 @@ class EVBinarySensor(BinarySensorDevice):
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, connection, config):
|
||||
def __init__(self, connection, config, car_vid):
|
||||
"""Initialize sensor with car connection."""
|
||||
self._conn = connection
|
||||
self._name = config.name
|
||||
self._attr = config.attr
|
||||
self._type = config.device_class
|
||||
self._is_on = None
|
||||
|
||||
self._car_vid = car_vid
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(
|
||||
'{}_{}'.format(MYCHEVY_DOMAIN, slugify(self._name)))
|
||||
'{}_{}_{}'.format(MYCHEVY_DOMAIN,
|
||||
slugify(self._car.name),
|
||||
slugify(self._name)))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@ -66,6 +69,11 @@ class EVBinarySensor(BinarySensorDevice):
|
||||
"""Return if on."""
|
||||
return self._is_on
|
||||
|
||||
@property
|
||||
def _car(self):
|
||||
"""Return the car."""
|
||||
return self._conn.get_car(self._car_vid)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
@ -75,8 +83,8 @@ class EVBinarySensor(BinarySensorDevice):
|
||||
@callback
|
||||
def async_update_callback(self):
|
||||
"""Update state."""
|
||||
if self._conn.car is not None:
|
||||
self._is_on = getattr(self._conn.car, self._attr, None)
|
||||
if self._car is not None:
|
||||
self._is_on = getattr(self._car, self._attr, None)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
|
@ -9,12 +9,24 @@ from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASSES, DOMAIN, BinarySensorDevice)
|
||||
from homeassistant.const import STATE_ON
|
||||
|
||||
SENSORS = {
|
||||
'S_DOOR': 'door',
|
||||
'S_MOTION': 'motion',
|
||||
'S_SMOKE': 'smoke',
|
||||
'S_SPRINKLER': 'safety',
|
||||
'S_WATER_LEAK': 'safety',
|
||||
'S_SOUND': 'sound',
|
||||
'S_VIBRATION': 'vibration',
|
||||
'S_MOISTURE': 'moisture',
|
||||
}
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the MySensors platform for binary sensors."""
|
||||
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the mysensors platform for binary sensors."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass, DOMAIN, discovery_info, MySensorsBinarySensor,
|
||||
add_devices=add_devices)
|
||||
async_add_devices=async_add_devices)
|
||||
|
||||
|
||||
class MySensorsBinarySensor(mysensors.MySensorsEntity, BinarySensorDevice):
|
||||
@ -29,18 +41,7 @@ class MySensorsBinarySensor(mysensors.MySensorsEntity, BinarySensorDevice):
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
pres = self.gateway.const.Presentation
|
||||
class_map = {
|
||||
pres.S_DOOR: 'opening',
|
||||
pres.S_MOTION: 'motion',
|
||||
pres.S_SMOKE: 'smoke',
|
||||
}
|
||||
if float(self.gateway.protocol_version) >= 1.5:
|
||||
class_map.update({
|
||||
pres.S_SPRINKLER: 'sprinkler',
|
||||
pres.S_WATER_LEAK: 'leak',
|
||||
pres.S_SOUND: 'sound',
|
||||
pres.S_VIBRATION: 'vibration',
|
||||
pres.S_MOISTURE: 'moisture',
|
||||
})
|
||||
if class_map.get(self.child_type) in DEVICE_CLASSES:
|
||||
return class_map.get(self.child_type)
|
||||
device_class = SENSORS.get(pres(self.child_type).name)
|
||||
if device_class in DEVICE_CLASSES:
|
||||
return device_class
|
||||
return None
|
||||
|
@ -7,27 +7,36 @@ https://home-assistant.io/components/binary_sensor.nest/
|
||||
from itertools import chain
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import (BinarySensorDevice)
|
||||
from homeassistant.components.sensor.nest import NestSensor
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.nest import DATA_NEST, NestSensorDevice
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
from homeassistant.components.nest import DATA_NEST
|
||||
|
||||
DEPENDENCIES = ['nest']
|
||||
|
||||
BINARY_TYPES = ['online']
|
||||
BINARY_TYPES = {'online': 'connectivity'}
|
||||
|
||||
CLIMATE_BINARY_TYPES = [
|
||||
'fan',
|
||||
'is_using_emergency_heat',
|
||||
'is_locked',
|
||||
'has_leaf',
|
||||
]
|
||||
CLIMATE_BINARY_TYPES = {
|
||||
'fan': None,
|
||||
'is_using_emergency_heat': 'heat',
|
||||
'is_locked': None,
|
||||
'has_leaf': None,
|
||||
}
|
||||
|
||||
CAMERA_BINARY_TYPES = [
|
||||
'motion_detected',
|
||||
'sound_detected',
|
||||
'person_detected',
|
||||
]
|
||||
CAMERA_BINARY_TYPES = {
|
||||
'motion_detected': 'motion',
|
||||
'sound_detected': 'sound',
|
||||
'person_detected': 'occupancy',
|
||||
}
|
||||
|
||||
STRUCTURE_BINARY_TYPES = {
|
||||
'away': None,
|
||||
# 'security_state', # pending python-nest update
|
||||
}
|
||||
|
||||
STRUCTURE_BINARY_STATE_MAP = {
|
||||
'away': {'away': True, 'home': False},
|
||||
'security_state': {'deter': True, 'ok': False},
|
||||
}
|
||||
|
||||
_BINARY_TYPES_DEPRECATED = [
|
||||
'hvac_ac_state',
|
||||
@ -40,8 +49,8 @@ _BINARY_TYPES_DEPRECATED = [
|
||||
'hvac_emer_heat_state',
|
||||
]
|
||||
|
||||
_VALID_BINARY_SENSOR_TYPES = BINARY_TYPES + CLIMATE_BINARY_TYPES \
|
||||
+ CAMERA_BINARY_TYPES
|
||||
_VALID_BINARY_SENSOR_TYPES = {**BINARY_TYPES, **CLIMATE_BINARY_TYPES,
|
||||
**CAMERA_BINARY_TYPES, **STRUCTURE_BINARY_TYPES}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -68,6 +77,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
_LOGGER.error(wstr)
|
||||
|
||||
sensors = []
|
||||
for structure in nest.structures():
|
||||
sensors += [NestBinarySensor(structure, None, variable)
|
||||
for variable in conditions
|
||||
if variable in STRUCTURE_BINARY_TYPES]
|
||||
device_chain = chain(nest.thermostats(),
|
||||
nest.smoke_co_alarms(),
|
||||
nest.cameras())
|
||||
@ -88,11 +101,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
sensors += [NestActivityZoneSensor(structure,
|
||||
device,
|
||||
activity_zone)]
|
||||
|
||||
add_devices(sensors, True)
|
||||
|
||||
|
||||
class NestBinarySensor(NestSensor, BinarySensorDevice):
|
||||
class NestBinarySensor(NestSensorDevice, BinarySensorDevice):
|
||||
"""Represents a Nest binary sensor."""
|
||||
|
||||
@property
|
||||
@ -100,9 +112,19 @@ class NestBinarySensor(NestSensor, BinarySensorDevice):
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class of the binary sensor."""
|
||||
return _VALID_BINARY_SENSOR_TYPES.get(self.variable)
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
self._state = bool(getattr(self.device, self.variable))
|
||||
value = getattr(self.device, self.variable)
|
||||
if self.variable in STRUCTURE_BINARY_TYPES:
|
||||
self._state = bool(STRUCTURE_BINARY_STATE_MAP
|
||||
[self.variable][value])
|
||||
else:
|
||||
self._state = bool(value)
|
||||
|
||||
|
||||
class NestActivityZoneSensor(NestBinarySensor):
|
||||
@ -115,9 +137,9 @@ class NestActivityZoneSensor(NestBinarySensor):
|
||||
self._name = "{} {} activity".format(self._name, self.zone.name)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the nest, if any."""
|
||||
return self._name
|
||||
def device_class(self):
|
||||
"""Return the device class of the binary sensor."""
|
||||
return 'motion'
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
|
@ -13,7 +13,6 @@ import voluptuous as vol
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.netatmo import CameraData
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.const import CONF_TIMEOUT
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
@ -61,7 +60,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the access to Netatmo binary sensor."""
|
||||
netatmo = get_component('netatmo')
|
||||
netatmo = hass.components.netatmo
|
||||
home = config.get(CONF_HOME)
|
||||
timeout = config.get(CONF_TIMEOUT)
|
||||
if timeout is None:
|
||||
|
70
homeassistant/components/binary_sensor/qwikswitch.py
Normal file
70
homeassistant/components/binary_sensor/qwikswitch.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""
|
||||
Support for Qwikswitch Binary Sensors.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.qwikswitch/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.qwikswitch import QSEntity, DOMAIN as QWIKSWITCH
|
||||
from homeassistant.core import callback
|
||||
|
||||
DEPENDENCIES = [QWIKSWITCH]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, _, add_devices, discovery_info=None):
|
||||
"""Add binary sensor from the main Qwikswitch component."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
qsusb = hass.data[QWIKSWITCH]
|
||||
_LOGGER.debug("Setup qwikswitch.binary_sensor %s, %s",
|
||||
qsusb, discovery_info)
|
||||
devs = [QSBinarySensor(sensor) for sensor in discovery_info[QWIKSWITCH]]
|
||||
add_devices(devs)
|
||||
|
||||
|
||||
class QSBinarySensor(QSEntity, BinarySensorDevice):
|
||||
"""Sensor based on a Qwikswitch relay/dimmer module."""
|
||||
|
||||
_val = False
|
||||
|
||||
def __init__(self, sensor):
|
||||
"""Initialize the sensor."""
|
||||
from pyqwikswitch import SENSORS
|
||||
|
||||
super().__init__(sensor['id'], sensor['name'])
|
||||
self.channel = sensor['channel']
|
||||
sensor_type = sensor['type']
|
||||
|
||||
self._decode, _ = SENSORS[sensor_type]
|
||||
self._invert = not sensor.get('invert', False)
|
||||
self._class = sensor.get('class', 'door')
|
||||
|
||||
@callback
|
||||
def update_packet(self, packet):
|
||||
"""Receive update packet from QSUSB."""
|
||||
val = self._decode(packet, channel=self.channel)
|
||||
_LOGGER.debug("Update %s (%s:%s) decoded as %s: %s",
|
||||
self.entity_id, self.qsid, self.channel, val, packet)
|
||||
if val is not None:
|
||||
self._val = bool(val)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Check if device is on (non-zero)."""
|
||||
return self._val == self._invert
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique identifier for this sensor."""
|
||||
return "qs{}:{}".format(self.qsid, self.channel)
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor."""
|
||||
return self._class
|
102
homeassistant/components/binary_sensor/rainmachine.py
Normal file
102
homeassistant/components/binary_sensor/rainmachine.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""
|
||||
This platform provides binary sensors for key RainMachine data.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.rainmachine/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.rainmachine import (
|
||||
BINARY_SENSORS, DATA_RAINMACHINE, DATA_UPDATE_TOPIC, TYPE_FREEZE,
|
||||
TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, TYPE_HOURLY, TYPE_MONTH,
|
||||
TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, RainMachineEntity)
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
DEPENDENCIES = ['rainmachine']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the RainMachine Switch platform."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
rainmachine = hass.data[DATA_RAINMACHINE]
|
||||
|
||||
binary_sensors = []
|
||||
for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
|
||||
name, icon = BINARY_SENSORS[sensor_type]
|
||||
binary_sensors.append(
|
||||
RainMachineBinarySensor(rainmachine, sensor_type, name, icon))
|
||||
|
||||
add_devices(binary_sensors, True)
|
||||
|
||||
|
||||
class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice):
|
||||
"""A sensor implementation for raincloud device."""
|
||||
|
||||
def __init__(self, rainmachine, sensor_type, name, icon):
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(rainmachine)
|
||||
|
||||
self._icon = icon
|
||||
self._name = name
|
||||
self._sensor_type = sensor_type
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return the icon."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the status of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Disable polling."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique, HASS-friendly identifier for this entity."""
|
||||
return '{0}_{1}'.format(
|
||||
self.rainmachine.device_mac.replace(':', ''), self._sensor_type)
|
||||
|
||||
@callback
|
||||
def update_data(self):
|
||||
"""Update the state."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
async_dispatcher_connect(self.hass, DATA_UPDATE_TOPIC,
|
||||
self.update_data)
|
||||
|
||||
def update(self):
|
||||
"""Update the state."""
|
||||
if self._sensor_type == TYPE_FREEZE:
|
||||
self._state = self.rainmachine.restrictions['current']['freeze']
|
||||
elif self._sensor_type == TYPE_FREEZE_PROTECTION:
|
||||
self._state = self.rainmachine.restrictions['global'][
|
||||
'freezeProtectEnabled']
|
||||
elif self._sensor_type == TYPE_HOT_DAYS:
|
||||
self._state = self.rainmachine.restrictions['global'][
|
||||
'hotDaysExtraWatering']
|
||||
elif self._sensor_type == TYPE_HOURLY:
|
||||
self._state = self.rainmachine.restrictions['current']['hourly']
|
||||
elif self._sensor_type == TYPE_MONTH:
|
||||
self._state = self.rainmachine.restrictions['current']['month']
|
||||
elif self._sensor_type == TYPE_RAINDELAY:
|
||||
self._state = self.rainmachine.restrictions['current']['rainDelay']
|
||||
elif self._sensor_type == TYPE_RAINSENSOR:
|
||||
self._state = self.rainmachine.restrictions['current'][
|
||||
'rainSensor']
|
||||
elif self._sensor_type == TYPE_WEEKDAY:
|
||||
self._state = self.rainmachine.restrictions['current']['weekDay']
|
@ -4,7 +4,6 @@ Support for showing random states.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.random/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
@ -24,8 +23,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Random binary sensor."""
|
||||
name = config.get(CONF_NAME)
|
||||
device_class = config.get(CONF_DEVICE_CLASS)
|
||||
@ -57,8 +56,7 @@ class RandomSensor(BinarySensorDevice):
|
||||
"""Return the sensor class of the sensor."""
|
||||
return self._device_class
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
async def async_update(self):
|
||||
"""Get new state and update the sensor's state."""
|
||||
from random import getrandbits
|
||||
self._state = bool(getrandbits(1))
|
||||
|
@ -10,7 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import rfxtrx
|
||||
from homeassistant.components.binary_sensor import (
|
||||
PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA, BinarySensorDevice)
|
||||
DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
|
||||
from homeassistant.components.rfxtrx import (
|
||||
ATTR_NAME, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_DEVICES,
|
||||
CONF_FIRE_EVENT, CONF_OFF_DELAY)
|
||||
@ -29,8 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_DEVICES, default={}): {
|
||||
cv.string: vol.Schema({
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS):
|
||||
DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
|
||||
vol.Optional(CONF_OFF_DELAY):
|
||||
vol.Any(cv.time_period, cv.positive_timedelta),
|
||||
|
@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
|
||||
from homeassistant.const import CONF_NAME
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['tapsaff==0.1.3']
|
||||
REQUIREMENTS = ['tapsaff==0.2.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -23,7 +23,7 @@ from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.util import utcnow
|
||||
|
||||
REQUIREMENTS = ['numpy==1.14.0']
|
||||
REQUIREMENTS = ['numpy==1.14.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -7,7 +7,6 @@ https://home-assistant.io/components/binary_sensor.wemo/
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.loader import get_component
|
||||
|
||||
DEPENDENCIES = ['wemo']
|
||||
|
||||
@ -25,18 +24,18 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
device = discovery.device_from_description(location, mac)
|
||||
|
||||
if device:
|
||||
add_devices_callback([WemoBinarySensor(device)])
|
||||
add_devices_callback([WemoBinarySensor(hass, device)])
|
||||
|
||||
|
||||
class WemoBinarySensor(BinarySensorDevice):
|
||||
"""Representation a WeMo binary sensor."""
|
||||
|
||||
def __init__(self, device):
|
||||
def __init__(self, hass, device):
|
||||
"""Initialize the WeMo sensor."""
|
||||
self.wemo = device
|
||||
self._state = None
|
||||
|
||||
wemo = get_component('wemo')
|
||||
wemo = hass.components.wemo
|
||||
wemo.SUBSCRIPTION_REGISTRY.register(self.wemo)
|
||||
wemo.SUBSCRIPTION_REGISTRY.on(self.wemo, None, self._update_callback)
|
||||
|
||||
|
@ -17,21 +17,22 @@ import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REQUIREMENTS = ['holidays==0.9.4']
|
||||
REQUIREMENTS = ['holidays==0.9.5']
|
||||
|
||||
# List of all countries currently supported by holidays
|
||||
# There seems to be no way to get the list out at runtime
|
||||
ALL_COUNTRIES = ['Australia', 'AU', 'Austria', 'AT', 'Belgium', 'BE', 'Canada',
|
||||
'CA', 'Colombia', 'CO', 'Czech', 'CZ', 'Denmark', 'DK',
|
||||
'England', 'EuropeanCentralBank', 'ECB', 'TAR', 'Finland',
|
||||
'FI', 'France', 'FRA', 'Germany', 'DE', 'Ireland',
|
||||
'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', 'Mexico', 'MX',
|
||||
'Netherlands', 'NL', 'NewZealand', 'NZ', 'Northern Ireland',
|
||||
ALL_COUNTRIES = ['Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT',
|
||||
'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech',
|
||||
'CZ', 'Denmark', 'DK', 'England', 'EuropeanCentralBank',
|
||||
'ECB', 'TAR', 'Finland', 'FI', 'France', 'FRA', 'Germany',
|
||||
'DE', 'Hungary', 'HU', 'Ireland', 'Isle of Man', 'Italy',
|
||||
'IT', 'Japan', 'JP', 'Mexico', 'MX', 'Netherlands', 'NL',
|
||||
'NewZealand', 'NZ', 'Northern Ireland',
|
||||
'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT',
|
||||
'PortugalExt', 'PTE', 'Scotland', 'Slovenia', 'SI',
|
||||
'Slovakia', 'SK', 'South Africa', 'ZA', 'Spain', 'ES',
|
||||
'Sweden', 'SE', 'UnitedKingdom', 'UK', 'UnitedStates', 'US',
|
||||
'Wales']
|
||||
'Sweden', 'SE', 'Switzerland', 'CH', 'UnitedKingdom', 'UK',
|
||||
'UnitedStates', 'US', 'Wales']
|
||||
CONF_COUNTRY = 'country'
|
||||
CONF_PROVINCE = 'province'
|
||||
CONF_WORKDAYS = 'workdays'
|
||||
@ -47,13 +48,13 @@ DEFAULT_OFFSET = 0
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES),
|
||||
vol.Optional(CONF_PROVINCE): cv.string,
|
||||
vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES):
|
||||
vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int),
|
||||
vol.Optional(CONF_PROVINCE): cv.string,
|
||||
vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS):
|
||||
vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]),
|
||||
vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES):
|
||||
vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]),
|
||||
})
|
||||
|
||||
|
||||
@ -74,14 +75,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
if province:
|
||||
# 'state' and 'prov' are not interchangeable, so need to make
|
||||
# sure we use the right one
|
||||
if (hasattr(obj_holidays, "PROVINCES") and
|
||||
if (hasattr(obj_holidays, 'PROVINCES') and
|
||||
province in obj_holidays.PROVINCES):
|
||||
obj_holidays = getattr(holidays, country)(prov=province,
|
||||
years=year)
|
||||
elif (hasattr(obj_holidays, "STATES") and
|
||||
obj_holidays = getattr(holidays, country)(
|
||||
prov=province, years=year)
|
||||
elif (hasattr(obj_holidays, 'STATES') and
|
||||
province in obj_holidays.STATES):
|
||||
obj_holidays = getattr(holidays, country)(state=province,
|
||||
years=year)
|
||||
obj_holidays = getattr(holidays, country)(
|
||||
state=province, years=year)
|
||||
else:
|
||||
_LOGGER.error("There is no province/state %s in country %s",
|
||||
province, country)
|
||||
|
@ -25,30 +25,35 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items():
|
||||
for device in gateway.devices['binary_sensor']:
|
||||
model = device['model']
|
||||
if model in ['motion', 'sensor_motion.aq2']:
|
||||
if model in ['motion', 'sensor_motion', 'sensor_motion.aq2']:
|
||||
devices.append(XiaomiMotionSensor(device, hass, gateway))
|
||||
elif model in ['magnet', 'sensor_magnet.aq2']:
|
||||
elif model in ['magnet', 'sensor_magnet', 'sensor_magnet.aq2']:
|
||||
devices.append(XiaomiDoorSensor(device, gateway))
|
||||
elif model == 'sensor_wleak.aq1':
|
||||
devices.append(XiaomiWaterLeakSensor(device, gateway))
|
||||
elif model == 'smoke':
|
||||
elif model in ['smoke', 'sensor_smoke']:
|
||||
devices.append(XiaomiSmokeSensor(device, gateway))
|
||||
elif model == 'natgas':
|
||||
elif model in ['natgas', 'sensor_natgas']:
|
||||
devices.append(XiaomiNatgasSensor(device, gateway))
|
||||
elif model in ['switch', 'sensor_switch.aq2', 'sensor_switch.aq3']:
|
||||
devices.append(XiaomiButton(device, 'Switch', 'status',
|
||||
elif model in ['switch', 'sensor_switch',
|
||||
'sensor_switch.aq2', 'sensor_switch.aq3']:
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
data_key = 'status'
|
||||
else:
|
||||
data_key = 'channel_0'
|
||||
devices.append(XiaomiButton(device, 'Switch', data_key,
|
||||
hass, gateway))
|
||||
elif model == '86sw1':
|
||||
elif model in ['86sw1', 'sensor_86sw1', 'sensor_86sw1.aq1']:
|
||||
devices.append(XiaomiButton(device, 'Wall Switch', 'channel_0',
|
||||
hass, gateway))
|
||||
elif model == '86sw2':
|
||||
elif model in ['86sw2', 'sensor_86sw2', 'sensor_86sw2.aq1']:
|
||||
devices.append(XiaomiButton(device, 'Wall Switch (Left)',
|
||||
'channel_0', hass, gateway))
|
||||
devices.append(XiaomiButton(device, 'Wall Switch (Right)',
|
||||
'channel_1', hass, gateway))
|
||||
devices.append(XiaomiButton(device, 'Wall Switch (Both)',
|
||||
'dual_channel', hass, gateway))
|
||||
elif model == 'cube':
|
||||
elif model in ['cube', 'sensor_cube', 'sensor_cube.aqgl01']:
|
||||
devices.append(XiaomiCube(device, hass, gateway))
|
||||
add_devices(devices)
|
||||
|
||||
@ -129,8 +134,12 @@ class XiaomiMotionSensor(XiaomiBinarySensor):
|
||||
"""Initialize the XiaomiMotionSensor."""
|
||||
self._hass = hass
|
||||
self._no_motion_since = 0
|
||||
if 'proto' not in device or int(device['proto'][0:1]) == 1:
|
||||
data_key = 'status'
|
||||
else:
|
||||
data_key = 'motion_status'
|
||||
XiaomiBinarySensor.__init__(self, device, 'Motion Sensor', xiaomi_hub,
|
||||
'status', 'motion')
|
||||
data_key, 'motion')
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
@ -321,6 +330,8 @@ class XiaomiButton(XiaomiBinarySensor):
|
||||
click_type = 'both'
|
||||
elif value == 'shake':
|
||||
click_type = 'shake'
|
||||
elif value == 'long_click':
|
||||
return False
|
||||
else:
|
||||
_LOGGER.warning("Unsupported click_type detected: %s", value)
|
||||
return False
|
||||
|
@ -31,12 +31,21 @@ async def async_setup_platform(hass, config, async_add_devices,
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
from zigpy.zcl.clusters.general import OnOff
|
||||
from zigpy.zcl.clusters.security import IasZone
|
||||
if IasZone.cluster_id in discovery_info['in_clusters']:
|
||||
await _async_setup_iaszone(hass, config, async_add_devices,
|
||||
discovery_info)
|
||||
elif OnOff.cluster_id in discovery_info['out_clusters']:
|
||||
await _async_setup_remote(hass, config, async_add_devices,
|
||||
discovery_info)
|
||||
|
||||
in_clusters = discovery_info['in_clusters']
|
||||
|
||||
async def _async_setup_iaszone(hass, config, async_add_devices,
|
||||
discovery_info):
|
||||
device_class = None
|
||||
cluster = in_clusters[IasZone.cluster_id]
|
||||
from zigpy.zcl.clusters.security import IasZone
|
||||
cluster = discovery_info['in_clusters'][IasZone.cluster_id]
|
||||
if discovery_info['new_join']:
|
||||
await cluster.bind()
|
||||
ieee = cluster.endpoint.device.application.ieee
|
||||
@ -53,8 +62,34 @@ async def async_setup_platform(hass, config, async_add_devices,
|
||||
async_add_devices([sensor], update_before_add=True)
|
||||
|
||||
|
||||
async def _async_setup_remote(hass, config, async_add_devices, discovery_info):
|
||||
|
||||
async def safe(coro):
|
||||
"""Run coro, catching ZigBee delivery errors, and ignoring them."""
|
||||
import zigpy.exceptions
|
||||
try:
|
||||
await coro
|
||||
except zigpy.exceptions.DeliveryError as exc:
|
||||
_LOGGER.warning("Ignoring error during setup: %s", exc)
|
||||
|
||||
if discovery_info['new_join']:
|
||||
from zigpy.zcl.clusters.general import OnOff, LevelControl
|
||||
out_clusters = discovery_info['out_clusters']
|
||||
if OnOff.cluster_id in out_clusters:
|
||||
cluster = out_clusters[OnOff.cluster_id]
|
||||
await safe(cluster.bind())
|
||||
await safe(cluster.configure_reporting(0, 0, 600, 1))
|
||||
if LevelControl.cluster_id in out_clusters:
|
||||
cluster = out_clusters[LevelControl.cluster_id]
|
||||
await safe(cluster.bind())
|
||||
await safe(cluster.configure_reporting(0, 1, 600, 1))
|
||||
|
||||
sensor = Switch(**discovery_info)
|
||||
async_add_devices([sensor], update_before_add=True)
|
||||
|
||||
|
||||
class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
"""THe ZHA Binary Sensor."""
|
||||
"""The ZHA Binary Sensor."""
|
||||
|
||||
_domain = DOMAIN
|
||||
|
||||
@ -73,7 +108,7 @@ class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if entity is on."""
|
||||
if self._state == 'unknown':
|
||||
if self._state is None:
|
||||
return False
|
||||
return bool(self._state)
|
||||
|
||||
@ -98,7 +133,126 @@ class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
from bellows.types.basic import uint16_t
|
||||
|
||||
result = await zha.safe_read(self._endpoint.ias_zone,
|
||||
['zone_status'])
|
||||
['zone_status'],
|
||||
allow_cache=False)
|
||||
state = result.get('zone_status', self._state)
|
||||
if isinstance(state, (int, uint16_t)):
|
||||
self._state = result.get('zone_status', self._state) & 3
|
||||
|
||||
|
||||
class Switch(zha.Entity, BinarySensorDevice):
|
||||
"""ZHA switch/remote controller/button."""
|
||||
|
||||
_domain = DOMAIN
|
||||
|
||||
class OnOffListener:
|
||||
"""Listener for the OnOff ZigBee cluster."""
|
||||
|
||||
def __init__(self, entity):
|
||||
"""Initialize OnOffListener."""
|
||||
self._entity = entity
|
||||
|
||||
def cluster_command(self, tsn, command_id, args):
|
||||
"""Handle commands received to this cluster."""
|
||||
if command_id in (0x0000, 0x0040):
|
||||
self._entity.set_state(False)
|
||||
elif command_id in (0x0001, 0x0041, 0x0042):
|
||||
self._entity.set_state(True)
|
||||
elif command_id == 0x0002:
|
||||
self._entity.set_state(not self._entity.is_on)
|
||||
|
||||
def attribute_updated(self, attrid, value):
|
||||
"""Handle attribute updates on this cluster."""
|
||||
if attrid == 0:
|
||||
self._entity.set_state(value)
|
||||
|
||||
def zdo_command(self, *args, **kwargs):
|
||||
"""Handle ZDO commands on this cluster."""
|
||||
pass
|
||||
|
||||
class LevelListener:
|
||||
"""Listener for the LevelControl ZigBee cluster."""
|
||||
|
||||
def __init__(self, entity):
|
||||
"""Initialize LevelListener."""
|
||||
self._entity = entity
|
||||
|
||||
def cluster_command(self, tsn, command_id, args):
|
||||
"""Handle commands received to this cluster."""
|
||||
if command_id in (0x0000, 0x0004): # move_to_level, -with_on_off
|
||||
self._entity.set_level(args[0])
|
||||
elif command_id in (0x0001, 0x0005): # move, -with_on_off
|
||||
# We should dim slowly -- for now, just step once
|
||||
rate = args[1]
|
||||
if args[0] == 0xff:
|
||||
rate = 10 # Should read default move rate
|
||||
self._entity.move_level(-rate if args[0] else rate)
|
||||
elif command_id == 0x0002: # step
|
||||
# Step (technically shouldn't change on/off)
|
||||
self._entity.move_level(-args[1] if args[0] else args[1])
|
||||
|
||||
def attribute_update(self, attrid, value):
|
||||
"""Handle attribute updates on this cluster."""
|
||||
if attrid == 0:
|
||||
self._entity.set_level(value)
|
||||
|
||||
def zdo_command(self, *args, **kwargs):
|
||||
"""Handle ZDO commands on this cluster."""
|
||||
pass
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize Switch."""
|
||||
super().__init__(**kwargs)
|
||||
self._state = False
|
||||
self._level = 0
|
||||
from zigpy.zcl.clusters import general
|
||||
self._out_listeners = {
|
||||
general.OnOff.cluster_id: self.OnOffListener(self),
|
||||
general.LevelControl.cluster_id: self.LevelListener(self),
|
||||
}
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Let zha handle polling."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device state attributes."""
|
||||
self._device_state_attributes.update({
|
||||
'level': self._state and self._level or 0
|
||||
})
|
||||
return self._device_state_attributes
|
||||
|
||||
def move_level(self, change):
|
||||
"""Increment the level, setting state if appropriate."""
|
||||
if not self._state and change > 0:
|
||||
self._level = 0
|
||||
self._level = min(255, max(0, self._level + change))
|
||||
self._state = bool(self._level)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
def set_level(self, level):
|
||||
"""Set the level, setting state if appropriate."""
|
||||
self._level = level
|
||||
self._state = bool(self._level)
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
def set_state(self, state):
|
||||
"""Set the state."""
|
||||
self._state = state
|
||||
if self._level == 0:
|
||||
self._level = 255
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def async_update(self):
|
||||
"""Retrieve latest state."""
|
||||
from zigpy.zcl.clusters.general import OnOff
|
||||
result = await zha.safe_read(
|
||||
self._endpoint.out_clusters[OnOff.cluster_id], ['on_off'])
|
||||
self._state = result.get('on_off', self._state)
|
||||
|
@ -1,105 +0,0 @@
|
||||
"""
|
||||
Reads vehicle status from BMW connected drive portal.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/bmw_connected_drive/
|
||||
"""
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.event import track_utc_time_change
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import (
|
||||
CONF_USERNAME, CONF_PASSWORD
|
||||
)
|
||||
|
||||
REQUIREMENTS = ['bimmer_connected==0.4.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = 'bmw_connected_drive'
|
||||
CONF_VALUES = 'values'
|
||||
CONF_COUNTRY = 'country'
|
||||
|
||||
ACCOUNT_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_COUNTRY): cv.string,
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: {
|
||||
cv.string: ACCOUNT_SCHEMA
|
||||
},
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
BMW_COMPONENTS = ['device_tracker', 'sensor']
|
||||
UPDATE_INTERVAL = 5 # in minutes
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the BMW connected drive components."""
|
||||
accounts = []
|
||||
for name, account_config in config[DOMAIN].items():
|
||||
username = account_config[CONF_USERNAME]
|
||||
password = account_config[CONF_PASSWORD]
|
||||
country = account_config[CONF_COUNTRY]
|
||||
_LOGGER.debug('Adding new account %s', name)
|
||||
bimmer = BMWConnectedDriveAccount(username, password, country, name)
|
||||
accounts.append(bimmer)
|
||||
|
||||
# update every UPDATE_INTERVAL minutes, starting now
|
||||
# this should even out the load on the servers
|
||||
|
||||
now = datetime.datetime.now()
|
||||
track_utc_time_change(
|
||||
hass, bimmer.update,
|
||||
minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL),
|
||||
second=now.second)
|
||||
|
||||
hass.data[DOMAIN] = accounts
|
||||
|
||||
for account in accounts:
|
||||
account.update()
|
||||
|
||||
for component in BMW_COMPONENTS:
|
||||
discovery.load_platform(hass, component, DOMAIN, {}, config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class BMWConnectedDriveAccount(object):
|
||||
"""Representation of a BMW vehicle."""
|
||||
|
||||
def __init__(self, username: str, password: str, country: str,
|
||||
name: str) -> None:
|
||||
"""Constructor."""
|
||||
from bimmer_connected.account import ConnectedDriveAccount
|
||||
|
||||
self.account = ConnectedDriveAccount(username, password, country)
|
||||
self.name = name
|
||||
self._update_listeners = []
|
||||
|
||||
def update(self, *_):
|
||||
"""Update the state of all vehicles.
|
||||
|
||||
Notify all listeners about the update.
|
||||
"""
|
||||
_LOGGER.debug('Updating vehicle state for account %s, '
|
||||
'notifying %d listeners',
|
||||
self.name, len(self._update_listeners))
|
||||
try:
|
||||
self.account.update_vehicle_states()
|
||||
for listener in self._update_listeners:
|
||||
listener()
|
||||
except IOError as exception:
|
||||
_LOGGER.error('Error updating the vehicle state.')
|
||||
_LOGGER.exception(exception)
|
||||
|
||||
def add_update_listener(self, listener):
|
||||
"""Add a listener for update notifications."""
|
||||
self._update_listeners.append(listener)
|
154
homeassistant/components/bmw_connected_drive/__init__.py
Normal file
154
homeassistant/components/bmw_connected_drive/__init__.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""
|
||||
Reads vehicle status from BMW connected drive portal.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/bmw_connected_drive/
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.event import track_utc_time_change
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['bimmer_connected==0.5.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = 'bmw_connected_drive'
|
||||
CONF_REGION = 'region'
|
||||
ATTR_VIN = 'vin'
|
||||
|
||||
ACCOUNT_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_REGION): vol.Any('north_america', 'china',
|
||||
'rest_of_world'),
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: {
|
||||
cv.string: ACCOUNT_SCHEMA
|
||||
},
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_VIN): cv.string,
|
||||
})
|
||||
|
||||
|
||||
BMW_COMPONENTS = ['binary_sensor', 'device_tracker', 'lock', 'sensor']
|
||||
UPDATE_INTERVAL = 5 # in minutes
|
||||
|
||||
SERVICE_UPDATE_STATE = 'update_state'
|
||||
|
||||
_SERVICE_MAP = {
|
||||
'light_flash': 'trigger_remote_light_flash',
|
||||
'sound_horn': 'trigger_remote_horn',
|
||||
'activate_air_conditioning': 'trigger_remote_air_conditioning',
|
||||
}
|
||||
|
||||
|
||||
def setup(hass, config: dict):
|
||||
"""Set up the BMW connected drive components."""
|
||||
accounts = []
|
||||
for name, account_config in config[DOMAIN].items():
|
||||
accounts.append(setup_account(account_config, hass, name))
|
||||
|
||||
hass.data[DOMAIN] = accounts
|
||||
|
||||
def _update_all(call) -> None:
|
||||
"""Update all BMW accounts."""
|
||||
for cd_account in hass.data[DOMAIN]:
|
||||
cd_account.update()
|
||||
|
||||
# Service to manually trigger updates for all accounts.
|
||||
hass.services.register(DOMAIN, SERVICE_UPDATE_STATE, _update_all)
|
||||
|
||||
_update_all(None)
|
||||
|
||||
for component in BMW_COMPONENTS:
|
||||
discovery.load_platform(hass, component, DOMAIN, {}, config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup_account(account_config: dict, hass, name: str) \
|
||||
-> 'BMWConnectedDriveAccount':
|
||||
"""Set up a new BMWConnectedDriveAccount based on the config."""
|
||||
username = account_config[CONF_USERNAME]
|
||||
password = account_config[CONF_PASSWORD]
|
||||
region = account_config[CONF_REGION]
|
||||
_LOGGER.debug('Adding new account %s', name)
|
||||
cd_account = BMWConnectedDriveAccount(username, password, region, name)
|
||||
|
||||
def execute_service(call):
|
||||
"""Execute a service for a vehicle.
|
||||
|
||||
This must be a member function as we need access to the cd_account
|
||||
object here.
|
||||
"""
|
||||
vin = call.data[ATTR_VIN]
|
||||
vehicle = cd_account.account.get_vehicle(vin)
|
||||
if not vehicle:
|
||||
_LOGGER.error('Could not find a vehicle for VIN "%s"!', vin)
|
||||
return
|
||||
function_name = _SERVICE_MAP[call.service]
|
||||
function_call = getattr(vehicle.remote_services, function_name)
|
||||
function_call()
|
||||
|
||||
# register the remote services
|
||||
for service in _SERVICE_MAP:
|
||||
hass.services.register(
|
||||
DOMAIN, service,
|
||||
execute_service,
|
||||
schema=SERVICE_SCHEMA)
|
||||
|
||||
# update every UPDATE_INTERVAL minutes, starting now
|
||||
# this should even out the load on the servers
|
||||
now = datetime.datetime.now()
|
||||
track_utc_time_change(
|
||||
hass, cd_account.update,
|
||||
minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL),
|
||||
second=now.second)
|
||||
|
||||
return cd_account
|
||||
|
||||
|
||||
class BMWConnectedDriveAccount(object):
|
||||
"""Representation of a BMW vehicle."""
|
||||
|
||||
def __init__(self, username: str, password: str, region_str: str,
|
||||
name: str) -> None:
|
||||
"""Constructor."""
|
||||
from bimmer_connected.account import ConnectedDriveAccount
|
||||
from bimmer_connected.country_selector import get_region_from_name
|
||||
|
||||
region = get_region_from_name(region_str)
|
||||
|
||||
self.account = ConnectedDriveAccount(username, password, region)
|
||||
self.name = name
|
||||
self._update_listeners = []
|
||||
|
||||
def update(self, *_):
|
||||
"""Update the state of all vehicles.
|
||||
|
||||
Notify all listeners about the update.
|
||||
"""
|
||||
_LOGGER.debug('Updating vehicle state for account %s, '
|
||||
'notifying %d listeners',
|
||||
self.name, len(self._update_listeners))
|
||||
try:
|
||||
self.account.update_vehicle_states()
|
||||
for listener in self._update_listeners:
|
||||
listener()
|
||||
except IOError as exception:
|
||||
_LOGGER.error('Error updating the vehicle state.')
|
||||
_LOGGER.exception(exception)
|
||||
|
||||
def add_update_listener(self, listener):
|
||||
"""Add a listener for update notifications."""
|
||||
self._update_listeners.append(listener)
|
42
homeassistant/components/bmw_connected_drive/services.yaml
Normal file
42
homeassistant/components/bmw_connected_drive/services.yaml
Normal file
@ -0,0 +1,42 @@
|
||||
# Describes the format for available services for bmw_connected_drive
|
||||
#
|
||||
# The services related to locking/unlocking are implemented in the lock
|
||||
# component to avoid redundancy.
|
||||
|
||||
light_flash:
|
||||
description: >
|
||||
Flash the lights of the vehicle. The vehicle is identified via the vin
|
||||
(see below).
|
||||
fields:
|
||||
vin:
|
||||
description: >
|
||||
The vehicle identification number (VIN) of the vehicle, 17 characters
|
||||
example: WBANXXXXXX1234567
|
||||
|
||||
sound_horn:
|
||||
description: >
|
||||
Sound the horn of the vehicle. The vehicle is identified via the vin
|
||||
(see below).
|
||||
fields:
|
||||
vin:
|
||||
description: >
|
||||
The vehicle identification number (VIN) of the vehicle, 17 characters
|
||||
example: WBANXXXXXX1234567
|
||||
|
||||
activate_air_conditioning:
|
||||
description: >
|
||||
Start the air conditioning of the vehicle. What exactly is started here
|
||||
depends on the type of vehicle. It might range from just ventilation over
|
||||
auxiliary heating to real air conditioning. The vehicle is identified via
|
||||
the vin (see below).
|
||||
fields:
|
||||
vin:
|
||||
description: >
|
||||
The vehicle identification number (VIN) of the vehicle, 17 characters
|
||||
example: WBANXXXXXX1234567
|
||||
|
||||
update_state:
|
||||
description: >
|
||||
Fetch the last state of the vehicles of all your accounts from the BMW
|
||||
server. This does *not* trigger an update from the vehicle, it just gets
|
||||
the data from the BMW servers. This service does not require any attributes.
|
@ -194,7 +194,9 @@ class WebDavCalendarData(object):
|
||||
@staticmethod
|
||||
def is_over(vevent):
|
||||
"""Return if the event is over."""
|
||||
return dt.now() > WebDavCalendarData.get_end_date(vevent)
|
||||
return dt.now() >= WebDavCalendarData.to_datetime(
|
||||
WebDavCalendarData.get_end_date(vevent)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_hass_date(obj):
|
||||
@ -230,4 +232,4 @@ class WebDavCalendarData(object):
|
||||
else:
|
||||
enddate = obj.dtstart.value + timedelta(days=1)
|
||||
|
||||
return WebDavCalendarData.to_datetime(enddate)
|
||||
return enddate
|
||||
|
@ -11,6 +11,7 @@ from datetime import timedelta
|
||||
from homeassistant.components.calendar import CalendarEventDevice
|
||||
from homeassistant.components.google import (
|
||||
CONF_CAL_ID, CONF_ENTITIES, CONF_TRACK, TOKEN_FILE,
|
||||
CONF_IGNORE_AVAILABILITY, CONF_SEARCH,
|
||||
GoogleCalendarService)
|
||||
from homeassistant.util import Throttle, dt
|
||||
|
||||
@ -18,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GOOGLE_SEARCH_PARAMS = {
|
||||
'orderBy': 'startTime',
|
||||
'maxResults': 1,
|
||||
'maxResults': 5,
|
||||
'singleEvents': True,
|
||||
}
|
||||
|
||||
@ -45,24 +46,35 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
||||
def __init__(self, hass, calendar_service, calendar, data):
|
||||
"""Create the Calendar event device."""
|
||||
self.data = GoogleCalendarData(calendar_service, calendar,
|
||||
data.get('search', None))
|
||||
data.get(CONF_SEARCH),
|
||||
data.get(CONF_IGNORE_AVAILABILITY))
|
||||
|
||||
super().__init__(hass, data)
|
||||
|
||||
|
||||
class GoogleCalendarData(object):
|
||||
"""Class to utilize calendar service object to get next event."""
|
||||
|
||||
def __init__(self, calendar_service, calendar_id, search=None):
|
||||
def __init__(self, calendar_service, calendar_id, search,
|
||||
ignore_availability):
|
||||
"""Set up how we are going to search the google calendar."""
|
||||
self.calendar_service = calendar_service
|
||||
self.calendar_id = calendar_id
|
||||
self.search = search
|
||||
self.ignore_availability = ignore_availability
|
||||
self.event = None
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
"""Get the latest data."""
|
||||
from httplib2 import ServerNotFoundError
|
||||
|
||||
try:
|
||||
service = self.calendar_service.get()
|
||||
except ServerNotFoundError:
|
||||
_LOGGER.warning("Unable to connect to Google, using cached data")
|
||||
return False
|
||||
|
||||
params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS)
|
||||
params['timeMin'] = dt.now().isoformat('T')
|
||||
params['calendarId'] = self.calendar_id
|
||||
@ -73,5 +85,17 @@ class GoogleCalendarData(object):
|
||||
result = events.list(**params).execute()
|
||||
|
||||
items = result.get('items', [])
|
||||
self.event = items[0] if len(items) == 1 else None
|
||||
|
||||
new_event = None
|
||||
for item in items:
|
||||
if (not self.ignore_availability
|
||||
and 'transparency' in item.keys()):
|
||||
if item['transparency'] == 'opaque':
|
||||
new_event = item
|
||||
break
|
||||
else:
|
||||
new_event = item
|
||||
break
|
||||
|
||||
self.event = new_event
|
||||
return True
|
||||
|
@ -1,21 +1,26 @@
|
||||
# Describes the format for available calendar services
|
||||
|
||||
todoist:
|
||||
new_task:
|
||||
todoist_new_task:
|
||||
description: Create a new task and add it to a project.
|
||||
fields:
|
||||
content:
|
||||
description: The name of the task (Required).
|
||||
description: The name of the task.
|
||||
example: Pick up the mail
|
||||
project:
|
||||
description: The name of the project this task should belong to. Defaults to Inbox (Optional).
|
||||
description: The name of the project this task should belong to. Defaults to Inbox.
|
||||
example: Errands
|
||||
labels:
|
||||
description: Any labels that you want to apply to this task, separated by a comma (Optional).
|
||||
description: Any labels that you want to apply to this task, separated by a comma.
|
||||
example: Chores,Deliveries
|
||||
priority:
|
||||
description: The priority of this task, from 1 (normal) to 4 (urgent) (Optional).
|
||||
description: The priority of this task, from 1 (normal) to 4 (urgent).
|
||||
example: 2
|
||||
due_date_string:
|
||||
description: The day this task is due, in natural language.
|
||||
example: "tomorrow"
|
||||
due_date_lang:
|
||||
description: The language of due_date_string.
|
||||
example: "en"
|
||||
due_date:
|
||||
description: The day this task is due, in format YYYY-MM-DD (Optional).
|
||||
description: The day this task is due, in format YYYY-MM-DD.
|
||||
example: "2018-04-01"
|
||||
|
@ -41,6 +41,14 @@ CONTENT = 'content'
|
||||
DESCRIPTION = 'description'
|
||||
# Calendar Platform: Used in the '_get_date()' method
|
||||
DATETIME = 'dateTime'
|
||||
# Service Call: When is this task due (in natural language)?
|
||||
DUE_DATE_STRING = 'due_date_string'
|
||||
# Service Call: The language of DUE_DATE_STRING
|
||||
DUE_DATE_LANG = 'due_date_lang'
|
||||
# Service Call: The available options of DUE_DATE_LANG
|
||||
DUE_DATE_VALID_LANGS = ['en', 'da', 'pl', 'zh', 'ko', 'de',
|
||||
'pt', 'ja', 'it', 'fr', 'sv', 'ru',
|
||||
'es', 'nl']
|
||||
# Attribute: When is this task due?
|
||||
# Service Call: When is this task due?
|
||||
DUE_DATE = 'due_date'
|
||||
@ -83,7 +91,11 @@ NEW_TASK_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower),
|
||||
vol.Optional(LABELS): cv.ensure_list_csv,
|
||||
vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
|
||||
vol.Optional(DUE_DATE): cv.string,
|
||||
|
||||
vol.Exclusive(DUE_DATE_STRING, 'due_date'): cv.string,
|
||||
vol.Optional(DUE_DATE_LANG):
|
||||
vol.All(cv.string, vol.In(DUE_DATE_VALID_LANGS)),
|
||||
vol.Exclusive(DUE_DATE, 'due_date'): cv.string,
|
||||
})
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
@ -186,6 +198,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
if PRIORITY in call.data:
|
||||
item.update(priority=call.data[PRIORITY])
|
||||
|
||||
if DUE_DATE_STRING in call.data:
|
||||
item.update(date_string=call.data[DUE_DATE_STRING])
|
||||
|
||||
if DUE_DATE_LANG in call.data:
|
||||
item.update(date_lang=call.data[DUE_DATE_LANG])
|
||||
|
||||
if DUE_DATE in call.data:
|
||||
due_date = dt.parse_datetime(call.data[DUE_DATE])
|
||||
if due_date is None:
|
||||
@ -496,6 +514,10 @@ class TodoistProjectData(object):
|
||||
# We had no valid tasks
|
||||
return True
|
||||
|
||||
# Make sure the task collection is reset to prevent an
|
||||
# infinite collection repeating the same tasks
|
||||
self.all_project_tasks.clear()
|
||||
|
||||
# Organize the best tasks (so users can see all the tasks
|
||||
# they have, organized)
|
||||
while project_tasks:
|
||||
|
@ -6,6 +6,7 @@ For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera/
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import collections
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
@ -13,20 +14,20 @@ import logging
|
||||
import hashlib
|
||||
from random import SystemRandom
|
||||
|
||||
import aiohttp
|
||||
import attr
|
||||
from aiohttp import web
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
|
||||
from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED
|
||||
from homeassistant.components import websocket_api
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DOMAIN = 'camera'
|
||||
@ -53,6 +54,9 @@ ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}'
|
||||
TOKEN_CHANGE_INTERVAL = timedelta(minutes=5)
|
||||
_RND = SystemRandom()
|
||||
|
||||
FALLBACK_STREAM_INTERVAL = 1 # seconds
|
||||
MIN_STREAM_INTERVAL = 0.5 # seconds
|
||||
|
||||
CAMERA_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
})
|
||||
@ -61,6 +65,20 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
|
||||
vol.Required(ATTR_FILENAME): cv.template
|
||||
})
|
||||
|
||||
WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail'
|
||||
SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
'type': WS_TYPE_CAMERA_THUMBNAIL,
|
||||
'entity_id': cv.entity_id
|
||||
})
|
||||
|
||||
|
||||
@attr.s
|
||||
class Image:
|
||||
"""Represent an image."""
|
||||
|
||||
content_type = attr.ib(type=str)
|
||||
content = attr.ib(type=bytes)
|
||||
|
||||
|
||||
@bind_hass
|
||||
def enable_motion_detection(hass, entity_id=None):
|
||||
@ -89,43 +107,40 @@ def async_snapshot(hass, filename, entity_id=None):
|
||||
|
||||
|
||||
@bind_hass
|
||||
@asyncio.coroutine
|
||||
def async_get_image(hass, entity_id, timeout=10):
|
||||
async def async_get_image(hass, entity_id, timeout=10):
|
||||
"""Fetch an image from a camera entity."""
|
||||
websession = async_get_clientsession(hass)
|
||||
state = hass.states.get(entity_id)
|
||||
component = hass.data.get(DOMAIN)
|
||||
|
||||
if state is None:
|
||||
raise HomeAssistantError(
|
||||
"No entity '{0}' for grab an image".format(entity_id))
|
||||
if component is None:
|
||||
raise HomeAssistantError('Camera component not setup')
|
||||
|
||||
url = "{0}{1}".format(
|
||||
hass.config.api.base_url,
|
||||
state.attributes.get(ATTR_ENTITY_PICTURE)
|
||||
)
|
||||
camera = component.get_entity(entity_id)
|
||||
|
||||
try:
|
||||
if camera is None:
|
||||
raise HomeAssistantError('Camera not found')
|
||||
|
||||
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
||||
with async_timeout.timeout(timeout, loop=hass.loop):
|
||||
response = yield from websession.get(url)
|
||||
image = await camera.async_camera_image()
|
||||
|
||||
if response.status != 200:
|
||||
raise HomeAssistantError("Error {0} on {1}".format(
|
||||
response.status, url))
|
||||
if image:
|
||||
return Image(camera.content_type, image)
|
||||
|
||||
image = yield from response.read()
|
||||
return image
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
||||
raise HomeAssistantError("Can't connect to {0}".format(url))
|
||||
raise HomeAssistantError('Unable to get image')
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Set up the camera component."""
|
||||
component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
|
||||
component = hass.data[DOMAIN] = \
|
||||
EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
hass.http.register_view(CameraImageView(component))
|
||||
hass.http.register_view(CameraMjpegStream(component))
|
||||
hass.components.websocket_api.async_register_command(
|
||||
WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail,
|
||||
SCHEMA_WS_CAMERA_THUMBNAIL
|
||||
)
|
||||
|
||||
yield from component.async_setup(config)
|
||||
|
||||
@ -241,6 +256,11 @@ class Camera(Entity):
|
||||
"""Return the camera model."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def frame_interval(self):
|
||||
"""Return the interval between frames of the mjpeg stream."""
|
||||
return 0.5
|
||||
|
||||
def camera_image(self):
|
||||
"""Return bytes of camera image."""
|
||||
raise NotImplementedError()
|
||||
@ -252,19 +272,17 @@ class Camera(Entity):
|
||||
"""
|
||||
return self.hass.async_add_job(self.camera_image)
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
async def handle_async_still_stream(self, request, interval):
|
||||
"""Generate an HTTP MJPEG stream from camera images.
|
||||
|
||||
This method must be run in the event loop.
|
||||
"""
|
||||
response = web.StreamResponse()
|
||||
|
||||
response.content_type = ('multipart/x-mixed-replace; '
|
||||
'boundary=--frameboundary')
|
||||
yield from response.prepare(request)
|
||||
await response.prepare(request)
|
||||
|
||||
async def write(img_bytes):
|
||||
async def write_to_mjpeg_stream(img_bytes):
|
||||
"""Write image to stream."""
|
||||
await response.write(bytes(
|
||||
'--frameboundary\r\n'
|
||||
@ -277,21 +295,21 @@ class Camera(Entity):
|
||||
|
||||
try:
|
||||
while True:
|
||||
img_bytes = yield from self.async_camera_image()
|
||||
img_bytes = await self.async_camera_image()
|
||||
if not img_bytes:
|
||||
break
|
||||
|
||||
if img_bytes and img_bytes != last_image:
|
||||
yield from write(img_bytes)
|
||||
await write_to_mjpeg_stream(img_bytes)
|
||||
|
||||
# Chrome seems to always ignore first picture,
|
||||
# print it twice.
|
||||
if last_image is None:
|
||||
yield from write(img_bytes)
|
||||
await write_to_mjpeg_stream(img_bytes)
|
||||
|
||||
last_image = img_bytes
|
||||
|
||||
yield from asyncio.sleep(.5)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("Stream closed by frontend.")
|
||||
@ -299,7 +317,16 @@ class Camera(Entity):
|
||||
|
||||
finally:
|
||||
if response is not None:
|
||||
yield from response.write_eof()
|
||||
await response.write_eof()
|
||||
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Serve an HTTP MJPEG stream from the camera.
|
||||
|
||||
This method can be overridden by camera plaforms to proxy
|
||||
a direct stream from the camera.
|
||||
This method must be run in the event loop.
|
||||
"""
|
||||
await self.handle_async_still_stream(request, self.frame_interval)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
@ -329,20 +356,20 @@ class Camera(Entity):
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return the camera state attributes."""
|
||||
attr = {
|
||||
attrs = {
|
||||
'access_token': self.access_tokens[-1],
|
||||
}
|
||||
|
||||
if self.model:
|
||||
attr['model_name'] = self.model
|
||||
attrs['model_name'] = self.model
|
||||
|
||||
if self.brand:
|
||||
attr['brand'] = self.brand
|
||||
attrs['brand'] = self.brand
|
||||
|
||||
if self.motion_detection_enabled:
|
||||
attr['motion_detection'] = self.motion_detection_enabled
|
||||
attrs['motion_detection'] = self.motion_detection_enabled
|
||||
|
||||
return attr
|
||||
return attrs
|
||||
|
||||
@callback
|
||||
def async_update_token(self):
|
||||
@ -411,7 +438,43 @@ class CameraMjpegStream(CameraView):
|
||||
url = '/api/camera_proxy_stream/{entity_id}'
|
||||
name = 'api:camera:stream'
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle(self, request, camera):
|
||||
"""Serve camera image."""
|
||||
yield from camera.handle_async_mjpeg_stream(request)
|
||||
async def handle(self, request, camera):
|
||||
"""Serve camera stream, possibly with interval."""
|
||||
interval = request.query.get('interval')
|
||||
if interval is None:
|
||||
await camera.handle_async_mjpeg_stream(request)
|
||||
return
|
||||
|
||||
try:
|
||||
# Compose camera stream from stills
|
||||
interval = float(request.query.get('interval'))
|
||||
if interval < MIN_STREAM_INTERVAL:
|
||||
raise ValueError("Stream interval must be be > {}"
|
||||
.format(MIN_STREAM_INTERVAL))
|
||||
await camera.handle_async_still_stream(request, interval)
|
||||
return
|
||||
except ValueError:
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@callback
|
||||
def websocket_camera_thumbnail(hass, connection, msg):
|
||||
"""Handle get camera thumbnail websocket command.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
async def send_camera_still():
|
||||
"""Send a camera still."""
|
||||
try:
|
||||
image = await async_get_image(hass, msg['entity_id'])
|
||||
connection.send_message_outside(websocket_api.result_message(
|
||||
msg['id'], {
|
||||
'content_type': image.content_type,
|
||||
'content': base64.b64encode(image.content).decode('utf-8')
|
||||
}
|
||||
))
|
||||
except HomeAssistantError:
|
||||
connection.send_message_outside(websocket_api.error_message(
|
||||
msg['id'], 'image_fetch_failed', 'Unable to fetch image'))
|
||||
|
||||
hass.async_add_job(send_camera_still())
|
||||
|
@ -9,7 +9,6 @@ import logging
|
||||
import requests
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.loader import get_component
|
||||
|
||||
DEPENDENCIES = ['bloomsky']
|
||||
|
||||
@ -17,7 +16,7 @@ DEPENDENCIES = ['bloomsky']
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up access to BloomSky cameras."""
|
||||
bloomsky = get_component('bloomsky')
|
||||
bloomsky = hass.components.bloomsky
|
||||
for device in bloomsky.BLOOMSKY.devices.values():
|
||||
add_devices([BloomSkyCamera(bloomsky.BLOOMSKY, device)])
|
||||
|
||||
|
58
homeassistant/components/camera/familyhub.py
Normal file
58
homeassistant/components/camera/familyhub.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""
|
||||
Family Hub camera for Samsung Refrigerators.
|
||||
|
||||
For more details about this platform, please refer to the documentation
|
||||
https://home-assistant.io/components/camera.familyhub/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REQUIREMENTS = ['python-family-hub-local==0.0.2']
|
||||
|
||||
DEFAULT_NAME = 'FamilyHub Camera'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_IP_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
})
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Family Hub Camera."""
|
||||
from pyfamilyhublocal import FamilyHubCam
|
||||
address = config.get(CONF_IP_ADDRESS)
|
||||
name = config.get(CONF_NAME)
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
family_hub_cam = FamilyHubCam(address, hass.loop, session)
|
||||
|
||||
async_add_devices([FamilyHubCamera(name, family_hub_cam)], True)
|
||||
|
||||
|
||||
class FamilyHubCamera(Camera):
|
||||
"""The representation of a Family Hub camera."""
|
||||
|
||||
def __init__(self, name, family_hub_cam):
|
||||
"""Initialize camera component."""
|
||||
super().__init__()
|
||||
self._name = name
|
||||
self.family_hub_cam = family_hub_cam
|
||||
|
||||
async def async_camera_image(self):
|
||||
"""Return a still image response."""
|
||||
return await self.family_hub_cam.async_get_cam_image()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this camera."""
|
||||
return self._name
|
@ -28,6 +28,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
CONF_CONTENT_TYPE = 'content_type'
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change'
|
||||
CONF_STILL_IMAGE_URL = 'still_image_url'
|
||||
CONF_FRAMERATE = 'framerate'
|
||||
|
||||
DEFAULT_NAME = 'Generic Camera'
|
||||
|
||||
@ -40,6 +41,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string,
|
||||
vol.Optional(CONF_FRAMERATE, default=2): cv.positive_int,
|
||||
})
|
||||
|
||||
|
||||
@ -62,6 +64,7 @@ class GenericCamera(Camera):
|
||||
self._still_image_url = device_info[CONF_STILL_IMAGE_URL]
|
||||
self._still_image_url.hass = hass
|
||||
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
||||
self._frame_interval = 1 / device_info[CONF_FRAMERATE]
|
||||
self.content_type = device_info[CONF_CONTENT_TYPE]
|
||||
|
||||
username = device_info.get(CONF_USERNAME)
|
||||
@ -78,6 +81,11 @@ class GenericCamera(Camera):
|
||||
self._last_url = None
|
||||
self._last_image = None
|
||||
|
||||
@property
|
||||
def frame_interval(self):
|
||||
"""Return the interval between frames of the mjpeg stream."""
|
||||
return self._frame_interval
|
||||
|
||||
def camera_image(self):
|
||||
"""Return bytes of camera image."""
|
||||
return run_coroutine_threadsafe(
|
||||
|
@ -11,31 +11,44 @@ import os
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
|
||||
from homeassistant.components.camera import (
|
||||
Camera, CAMERA_SERVICE_SCHEMA, DOMAIN, PLATFORM_SCHEMA)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_FILE_PATH = 'file_path'
|
||||
|
||||
DEFAULT_NAME = 'Local File'
|
||||
SERVICE_UPDATE_FILE_PATH = 'local_file_update_file_path'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_FILE_PATH): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
|
||||
})
|
||||
|
||||
CAMERA_SERVICE_UPDATE_FILE_PATH = CAMERA_SERVICE_SCHEMA.extend({
|
||||
vol.Required(CONF_FILE_PATH): cv.string
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Camera that works with local files."""
|
||||
file_path = config[CONF_FILE_PATH]
|
||||
camera = LocalFile(config[CONF_NAME], file_path)
|
||||
|
||||
# check filepath given is readable
|
||||
if not os.access(file_path, os.R_OK):
|
||||
_LOGGER.warning("Could not read camera %s image from file: %s",
|
||||
config[CONF_NAME], file_path)
|
||||
def update_file_path_service(call):
|
||||
"""Update the file path."""
|
||||
file_path = call.data.get(CONF_FILE_PATH)
|
||||
camera.update_file_path(file_path)
|
||||
return True
|
||||
|
||||
add_devices([LocalFile(config[CONF_NAME], file_path)])
|
||||
hass.services.register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_FILE_PATH,
|
||||
update_file_path_service,
|
||||
schema=CAMERA_SERVICE_UPDATE_FILE_PATH)
|
||||
|
||||
add_devices([camera])
|
||||
|
||||
|
||||
class LocalFile(Camera):
|
||||
@ -46,6 +59,7 @@ class LocalFile(Camera):
|
||||
super().__init__()
|
||||
|
||||
self._name = name
|
||||
self.check_file_path_access(file_path)
|
||||
self._file_path = file_path
|
||||
# Set content type of local file
|
||||
content, _ = mimetypes.guess_type(file_path)
|
||||
@ -61,7 +75,26 @@ class LocalFile(Camera):
|
||||
_LOGGER.warning("Could not read camera %s image from file: %s",
|
||||
self._name, self._file_path)
|
||||
|
||||
def check_file_path_access(self, file_path):
|
||||
"""Check that filepath given is readable."""
|
||||
if not os.access(file_path, os.R_OK):
|
||||
_LOGGER.warning("Could not read camera %s image from file: %s",
|
||||
self._name, file_path)
|
||||
|
||||
def update_file_path(self, file_path):
|
||||
"""Update the file_path."""
|
||||
self.check_file_path_access(file_path)
|
||||
self._file_path = file_path
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this camera."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the camera state attributes."""
|
||||
return {
|
||||
'file_path': self._file_path,
|
||||
}
|
||||
|
@ -19,7 +19,6 @@ from homeassistant.helpers import config_validation as cv
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_TOPIC = 'topic'
|
||||
|
||||
DEFAULT_NAME = 'MQTT Camera'
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
@ -33,9 +32,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the MQTT Camera."""
|
||||
topic = config[CONF_TOPIC]
|
||||
if discovery_info is not None:
|
||||
config = PLATFORM_SCHEMA(discovery_info)
|
||||
|
||||
async_add_devices([MqttCamera(config[CONF_NAME], topic)])
|
||||
async_add_devices([MqttCamera(
|
||||
config.get(CONF_NAME),
|
||||
config.get(CONF_TOPIC)
|
||||
)])
|
||||
|
||||
|
||||
class MqttCamera(Camera):
|
||||
|
@ -12,7 +12,6 @@ import voluptuous as vol
|
||||
from homeassistant.const import CONF_VERIFY_SSL
|
||||
from homeassistant.components.netatmo import CameraData
|
||||
from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA)
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
DEPENDENCIES = ['netatmo']
|
||||
@ -33,7 +32,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up access to Netatmo cameras."""
|
||||
netatmo = get_component('netatmo')
|
||||
netatmo = hass.components.netatmo
|
||||
home = config.get(CONF_HOME)
|
||||
verify_ssl = config.get(CONF_VERIFY_SSL, True)
|
||||
import lnetatmo
|
||||
|
@ -6,6 +6,7 @@ https://home-assistant.io/components/camera.onvif/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -103,92 +104,128 @@ class ONVIFHassCamera(Camera):
|
||||
|
||||
def __init__(self, hass, config):
|
||||
"""Initialize a ONVIF camera."""
|
||||
from onvif import ONVIFCamera, exceptions
|
||||
super().__init__()
|
||||
import onvif
|
||||
|
||||
self._username = config.get(CONF_USERNAME)
|
||||
self._password = config.get(CONF_PASSWORD)
|
||||
self._host = config.get(CONF_HOST)
|
||||
self._port = config.get(CONF_PORT)
|
||||
self._name = config.get(CONF_NAME)
|
||||
self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS)
|
||||
self._input = None
|
||||
camera = None
|
||||
try:
|
||||
_LOGGER.debug("Connecting with ONVIF Camera: %s on port %s",
|
||||
config.get(CONF_HOST), config.get(CONF_PORT))
|
||||
camera = ONVIFCamera(
|
||||
config.get(CONF_HOST), config.get(CONF_PORT),
|
||||
config.get(CONF_USERNAME), config.get(CONF_PASSWORD)
|
||||
)
|
||||
media_service = camera.create_media_service()
|
||||
self._profiles = media_service.GetProfiles()
|
||||
self._profile_index = config.get(CONF_PROFILE)
|
||||
if self._profile_index >= len(self._profiles):
|
||||
self._input = None
|
||||
self._media_service = \
|
||||
onvif.ONVIFService('http://{}:{}/onvif/device_service'.format(
|
||||
self._host, self._port),
|
||||
self._username, self._password,
|
||||
'{}/wsdl/media.wsdl'.format(os.path.dirname(
|
||||
onvif.__file__)))
|
||||
|
||||
self._ptz_service = \
|
||||
onvif.ONVIFService('http://{}:{}/onvif/device_service'.format(
|
||||
self._host, self._port),
|
||||
self._username, self._password,
|
||||
'{}/wsdl/ptz.wsdl'.format(os.path.dirname(
|
||||
onvif.__file__)))
|
||||
|
||||
def obtain_input_uri(self):
|
||||
"""Set the input uri for the camera."""
|
||||
from onvif import exceptions
|
||||
_LOGGER.debug("Connecting with ONVIF Camera: %s on port %s",
|
||||
self._host, self._port)
|
||||
|
||||
try:
|
||||
profiles = self._media_service.GetProfiles()
|
||||
|
||||
if self._profile_index >= len(profiles):
|
||||
_LOGGER.warning("ONVIF Camera '%s' doesn't provide profile %d."
|
||||
" Using the last profile.",
|
||||
self._name, self._profile_index)
|
||||
self._profile_index = -1
|
||||
req = media_service.create_type('GetStreamUri')
|
||||
|
||||
req = self._media_service.create_type('GetStreamUri')
|
||||
|
||||
# pylint: disable=protected-access
|
||||
req.ProfileToken = self._profiles[self._profile_index]._token
|
||||
self._input = media_service.GetStreamUri(req).Uri.replace(
|
||||
'rtsp://', 'rtsp://{}:{}@'.format(
|
||||
config.get(CONF_USERNAME),
|
||||
config.get(CONF_PASSWORD)), 1)
|
||||
req.ProfileToken = profiles[self._profile_index]._token
|
||||
uri_no_auth = self._media_service.GetStreamUri(req).Uri
|
||||
uri_for_log = uri_no_auth.replace(
|
||||
'rtsp://', 'rtsp://<user>:<password>@', 1)
|
||||
self._input = uri_no_auth.replace(
|
||||
'rtsp://', 'rtsp://{}:{}@'.format(self._username,
|
||||
self._password), 1)
|
||||
_LOGGER.debug(
|
||||
"ONVIF Camera Using the following URL for %s: %s",
|
||||
self._name, self._input)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Unable to communicate with ONVIF Camera: %s", err)
|
||||
raise
|
||||
try:
|
||||
self._ptz = camera.create_ptz_service()
|
||||
self._name, uri_for_log)
|
||||
# we won't need the media service anymore
|
||||
self._media_service = None
|
||||
except exceptions.ONVIFError as err:
|
||||
self._ptz = None
|
||||
_LOGGER.warning("Unable to setup PTZ for ONVIF Camera: %s", err)
|
||||
_LOGGER.debug("Couldn't setup camera '%s'. Error: %s",
|
||||
self._name, err)
|
||||
return
|
||||
|
||||
def perform_ptz(self, pan, tilt, zoom):
|
||||
"""Perform a PTZ action on the camera."""
|
||||
if self._ptz:
|
||||
from onvif import exceptions
|
||||
if self._ptz_service:
|
||||
pan_val = 1 if pan == DIR_RIGHT else -1 if pan == DIR_LEFT else 0
|
||||
tilt_val = 1 if tilt == DIR_UP else -1 if tilt == DIR_DOWN else 0
|
||||
zoom_val = 1 if zoom == ZOOM_IN else -1 if zoom == ZOOM_OUT else 0
|
||||
req = {"Velocity": {
|
||||
"PanTilt": {"_x": pan_val, "_y": tilt_val},
|
||||
"Zoom": {"_x": zoom_val}}}
|
||||
self._ptz.ContinuousMove(req)
|
||||
try:
|
||||
self._ptz_service.ContinuousMove(req)
|
||||
except exceptions.ONVIFError as err:
|
||||
if "Bad Request" in err.reason:
|
||||
self._ptz_service = None
|
||||
_LOGGER.debug("Camera '%s' doesn't support PTZ.",
|
||||
self._name)
|
||||
else:
|
||||
_LOGGER.debug("Camera '%s' doesn't support PTZ.", self._name)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self):
|
||||
"""Callback when entity is added to hass."""
|
||||
if ONVIF_DATA not in self.hass.data:
|
||||
self.hass.data[ONVIF_DATA] = {}
|
||||
self.hass.data[ONVIF_DATA][ENTITIES] = []
|
||||
self.hass.data[ONVIF_DATA][ENTITIES].append(self)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
async def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
from haffmpeg import ImageFrame, IMAGE_JPEG
|
||||
|
||||
if not self._input:
|
||||
await self.hass.async_add_job(self.obtain_input_uri)
|
||||
if not self._input:
|
||||
return None
|
||||
|
||||
ffmpeg = ImageFrame(
|
||||
self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop)
|
||||
|
||||
image = yield from asyncio.shield(ffmpeg.get_image(
|
||||
image = await asyncio.shield(ffmpeg.get_image(
|
||||
self._input, output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop)
|
||||
return image
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
from haffmpeg import CameraMjpeg
|
||||
|
||||
if not self._input:
|
||||
await self.hass.async_add_job(self.obtain_input_uri)
|
||||
if not self._input:
|
||||
return None
|
||||
|
||||
stream = CameraMjpeg(self.hass.data[DATA_FFMPEG].binary,
|
||||
loop=self.hass.loop)
|
||||
yield from stream.open_camera(
|
||||
await stream.open_camera(
|
||||
self._input, extra_cmd=self._ffmpeg_arguments)
|
||||
|
||||
yield from async_aiohttp_proxy_stream(
|
||||
await async_aiohttp_proxy_stream(
|
||||
self.hass, request, stream,
|
||||
'multipart/x-mixed-replace;boundary=ffserver')
|
||||
yield from stream.close()
|
||||
await stream.close()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -56,34 +56,6 @@ async def async_setup_platform(hass, config, async_add_devices,
|
||||
async_add_devices([ProxyCamera(hass, config)])
|
||||
|
||||
|
||||
async def _read_frame(req):
|
||||
"""Read a single frame from an MJPEG stream."""
|
||||
# based on https://gist.github.com/russss/1143799
|
||||
import cgi
|
||||
# Read in HTTP headers:
|
||||
stream = req.content
|
||||
# multipart/x-mixed-replace; boundary=--frameboundary
|
||||
_mimetype, options = cgi.parse_header(req.headers['content-type'])
|
||||
boundary = options.get('boundary').encode('utf-8')
|
||||
if not boundary:
|
||||
_LOGGER.error("Malformed MJPEG missing boundary")
|
||||
raise Exception("Can't find content-type")
|
||||
|
||||
line = await stream.readline()
|
||||
# Seek ahead to the first chunk
|
||||
while line.strip() != boundary:
|
||||
line = await stream.readline()
|
||||
# Read in chunk headers
|
||||
while line.strip() != b'':
|
||||
parts = line.split(b':')
|
||||
if len(parts) > 1 and parts[0].lower() == b'content-length':
|
||||
# Grab chunk length
|
||||
length = int(parts[1].strip())
|
||||
line = await stream.readline()
|
||||
image = await stream.read(length)
|
||||
return image
|
||||
|
||||
|
||||
def _resize_image(image, opts):
|
||||
"""Resize image."""
|
||||
from PIL import Image
|
||||
@ -227,9 +199,9 @@ class ProxyCamera(Camera):
|
||||
'boundary=--frameboundary')
|
||||
await response.prepare(request)
|
||||
|
||||
def write(img_bytes):
|
||||
async def write(img_bytes):
|
||||
"""Write image to stream."""
|
||||
response.write(bytes(
|
||||
await response.write(bytes(
|
||||
'--frameboundary\r\n'
|
||||
'Content-Type: {}\r\n'
|
||||
'Content-Length: {}\r\n\r\n'.format(
|
||||
@ -240,13 +212,23 @@ class ProxyCamera(Camera):
|
||||
req = await stream_coro
|
||||
|
||||
try:
|
||||
# This would be nicer as an async generator
|
||||
# But that would only be supported for python >=3.6
|
||||
data = b''
|
||||
stream = req.content
|
||||
while True:
|
||||
image = await _read_frame(req)
|
||||
if not image:
|
||||
chunk = await stream.read(102400)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
jpg_start = data.find(b'\xff\xd8')
|
||||
jpg_end = data.find(b'\xff\xd9')
|
||||
if jpg_start != -1 and jpg_end != -1:
|
||||
image = data[jpg_start:jpg_end + 2]
|
||||
image = await self.hass.async_add_job(
|
||||
_resize_image, image, self._stream_opts)
|
||||
write(image)
|
||||
await write(image)
|
||||
data = data[jpg_end + 2:]
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("Stream closed by frontend.")
|
||||
req.close()
|
||||
|
@ -24,6 +24,16 @@ snapshot:
|
||||
description: Template of a Filename. Variable is entity_id.
|
||||
example: '/tmp/snapshot_{{ entity_id }}'
|
||||
|
||||
local_file_update_file_path:
|
||||
description: Update the file_path for a local_file camera.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to update.
|
||||
example: 'camera.local_file'
|
||||
file_path:
|
||||
description: Path to the new image file.
|
||||
example: '/images/newimage.jpg'
|
||||
|
||||
onvif_ptz:
|
||||
description: Pan/Tilt/Zoom service for ONVIF camera.
|
||||
fields:
|
||||
@ -39,4 +49,3 @@ onvif_ptz:
|
||||
zoom:
|
||||
description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT"
|
||||
example: "ZOOM_IN"
|
||||
|
||||
|
@ -4,7 +4,6 @@ Support for Xeoma Cameras.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.xeoma/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
@ -14,7 +13,7 @@ from homeassistant.const import (
|
||||
CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyxeoma==1.3']
|
||||
REQUIREMENTS = ['pyxeoma==1.4.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -41,8 +40,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Discover and setup Xeoma Cameras."""
|
||||
from pyxeoma.xeoma import Xeoma, XeomaError
|
||||
|
||||
@ -53,8 +52,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
xeoma = Xeoma(host, login, password)
|
||||
|
||||
try:
|
||||
yield from xeoma.async_test_connection()
|
||||
discovered_image_names = yield from xeoma.async_get_image_names()
|
||||
await xeoma.async_test_connection()
|
||||
discovered_image_names = await xeoma.async_get_image_names()
|
||||
discovered_cameras = [
|
||||
{
|
||||
CONF_IMAGE_NAME: image_name,
|
||||
@ -103,12 +102,11 @@ class XeomaCamera(Camera):
|
||||
self._password = password
|
||||
self._last_image = None
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
async def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
from pyxeoma.xeoma import XeomaError
|
||||
try:
|
||||
image = yield from self._xeoma.async_get_camera_image(
|
||||
image = await self._xeoma.async_get_camera_image(
|
||||
self._image, self._username, self._password)
|
||||
self._last_image = image
|
||||
except XeomaError as err:
|
||||
|
@ -15,7 +15,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = ['py-canary==0.4.1']
|
||||
REQUIREMENTS = ['py-canary==0.5.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -22,6 +22,12 @@ from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
||||
STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS, PRECISION_WHOLE,
|
||||
PRECISION_TENTHS, )
|
||||
|
||||
DEFAULT_MIN_TEMP = 7
|
||||
DEFAULT_MAX_TEMP = 35
|
||||
DEFAULT_MIN_HUMITIDY = 30
|
||||
DEFAULT_MAX_HUMIDITY = 99
|
||||
|
||||
DOMAIN = 'climate'
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
@ -40,6 +46,7 @@ STATE_HEAT = 'heat'
|
||||
STATE_COOL = 'cool'
|
||||
STATE_IDLE = 'idle'
|
||||
STATE_AUTO = 'auto'
|
||||
STATE_MANUAL = 'manual'
|
||||
STATE_DRY = 'dry'
|
||||
STATE_FAN_ONLY = 'fan_only'
|
||||
STATE_ECO = 'eco'
|
||||
@ -777,19 +784,21 @@ class ClimateDevice(Entity):
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return convert_temperature(7, TEMP_CELSIUS, self.temperature_unit)
|
||||
return convert_temperature(DEFAULT_MIN_TEMP, TEMP_CELSIUS,
|
||||
self.temperature_unit)
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return convert_temperature(35, TEMP_CELSIUS, self.temperature_unit)
|
||||
return convert_temperature(DEFAULT_MAX_TEMP, TEMP_CELSIUS,
|
||||
self.temperature_unit)
|
||||
|
||||
@property
|
||||
def min_humidity(self):
|
||||
"""Return the minimum humidity."""
|
||||
return 30
|
||||
return DEFAULT_MIN_HUMITIDY
|
||||
|
||||
@property
|
||||
def max_humidity(self):
|
||||
"""Return the maximum humidity."""
|
||||
return 99
|
||||
return DEFAULT_MAX_HUMIDITY
|
||||
|
@ -14,10 +14,10 @@ from homeassistant.components.climate import (
|
||||
ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, SUPPORT_TARGET_TEMPERATURE,
|
||||
SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE,
|
||||
SUPPORT_TARGET_HUMIDITY_LOW, SUPPORT_TARGET_HUMIDITY_HIGH,
|
||||
SUPPORT_AUX_HEAT, SUPPORT_TARGET_TEMPERATURE_HIGH,
|
||||
SUPPORT_TARGET_TEMPERATURE_LOW)
|
||||
SUPPORT_AUX_HEAT, SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_FAN_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE_LOW, STATE_OFF)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, STATE_OFF, STATE_ON, ATTR_TEMPERATURE, TEMP_FAHRENHEIT)
|
||||
ATTR_ENTITY_ID, STATE_ON, ATTR_TEMPERATURE, TEMP_FAHRENHEIT)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_CONFIGURING = {}
|
||||
@ -50,7 +50,7 @@ SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE |
|
||||
SUPPORT_HOLD_MODE | SUPPORT_OPERATION_MODE |
|
||||
SUPPORT_TARGET_HUMIDITY_LOW | SUPPORT_TARGET_HUMIDITY_HIGH |
|
||||
SUPPORT_AUX_HEAT | SUPPORT_TARGET_TEMPERATURE_HIGH |
|
||||
SUPPORT_TARGET_TEMPERATURE_LOW)
|
||||
SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_FAN_MODE)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
@ -122,6 +122,7 @@ class Thermostat(ClimateDevice):
|
||||
self._climate_list = self.climate_list
|
||||
self._operation_list = ['auto', 'auxHeatOnly', 'cool',
|
||||
'heat', 'off']
|
||||
self._fan_list = ['auto', 'on']
|
||||
self.update_without_throttle = False
|
||||
|
||||
def update(self):
|
||||
@ -180,24 +181,29 @@ class Thermostat(ClimateDevice):
|
||||
return self.thermostat['runtime']['desiredCool'] / 10.0
|
||||
return None
|
||||
|
||||
@property
|
||||
def desired_fan_mode(self):
|
||||
"""Return the desired fan mode of operation."""
|
||||
return self.thermostat['runtime']['desiredFanMode']
|
||||
|
||||
@property
|
||||
def fan(self):
|
||||
"""Return the current fan state."""
|
||||
"""Return the current fan status."""
|
||||
if 'fan' in self.thermostat['equipmentStatus']:
|
||||
return STATE_ON
|
||||
return STATE_OFF
|
||||
|
||||
@property
|
||||
def current_fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
return self.thermostat['runtime']['desiredFanMode']
|
||||
|
||||
@property
|
||||
def current_hold_mode(self):
|
||||
"""Return current hold mode."""
|
||||
mode = self._current_hold_mode
|
||||
return None if mode == AWAY_MODE else mode
|
||||
|
||||
@property
|
||||
def fan_list(self):
|
||||
"""Return the available fan modes."""
|
||||
return self._fan_list
|
||||
|
||||
@property
|
||||
def _current_hold_mode(self):
|
||||
events = self.thermostat['events']
|
||||
@ -271,10 +277,11 @@ class Thermostat(ClimateDevice):
|
||||
operation = STATE_HEAT
|
||||
else:
|
||||
operation = status
|
||||
|
||||
return {
|
||||
"actual_humidity": self.thermostat['runtime']['actualHumidity'],
|
||||
"fan": self.fan,
|
||||
"mode": self.mode,
|
||||
"climate_mode": self.mode,
|
||||
"operation": operation,
|
||||
"climate_list": self.climate_list,
|
||||
"fan_min_on_time": self.fan_min_on_time
|
||||
@ -342,25 +349,46 @@ class Thermostat(ClimateDevice):
|
||||
cool_temp_setpoint, heat_temp_setpoint,
|
||||
self.hold_preference())
|
||||
_LOGGER.debug("Setting ecobee hold_temp to: heat=%s, is=%s, "
|
||||
"cool=%s, is=%s", heat_temp, isinstance(
|
||||
heat_temp, (int, float)), cool_temp,
|
||||
"cool=%s, is=%s", heat_temp,
|
||||
isinstance(heat_temp, (int, float)), cool_temp,
|
||||
isinstance(cool_temp, (int, float)))
|
||||
|
||||
self.update_without_throttle = True
|
||||
|
||||
def set_fan_mode(self, fan_mode):
|
||||
"""Set the fan mode. Valid values are "on" or "auto"."""
|
||||
if (fan_mode.lower() != STATE_ON) and (fan_mode.lower() != STATE_AUTO):
|
||||
error = "Invalid fan_mode value: Valid values are 'on' or 'auto'"
|
||||
_LOGGER.error(error)
|
||||
return
|
||||
|
||||
cool_temp = self.thermostat['runtime']['desiredCool'] / 10.0
|
||||
heat_temp = self.thermostat['runtime']['desiredHeat'] / 10.0
|
||||
self.data.ecobee.set_fan_mode(self.thermostat_index, fan_mode,
|
||||
cool_temp, heat_temp,
|
||||
self.hold_preference())
|
||||
|
||||
_LOGGER.info("Setting fan mode to: %s", fan_mode)
|
||||
|
||||
def set_temp_hold(self, temp):
|
||||
"""Set temperature hold in modes other than auto."""
|
||||
# Set arbitrary range when not in auto mode
|
||||
if self.current_operation == STATE_HEAT:
|
||||
"""Set temperature hold in modes other than auto.
|
||||
|
||||
Ecobee API: It is good practice to set the heat and cool hold
|
||||
temperatures to be the same, if the thermostat is in either heat, cool,
|
||||
auxHeatOnly, or off mode. If the thermostat is in auto mode, an
|
||||
additional rule is required. The cool hold temperature must be greater
|
||||
than the heat hold temperature by at least the amount in the
|
||||
heatCoolMinDelta property.
|
||||
https://www.ecobee.com/home/developer/api/examples/ex5.shtml
|
||||
"""
|
||||
if self.current_operation == STATE_HEAT or self.current_operation == \
|
||||
STATE_COOL:
|
||||
heat_temp = temp
|
||||
cool_temp = temp + 20
|
||||
elif self.current_operation == STATE_COOL:
|
||||
heat_temp = temp - 20
|
||||
cool_temp = temp
|
||||
else:
|
||||
# In auto mode set temperature between
|
||||
heat_temp = temp - 10
|
||||
cool_temp = temp + 10
|
||||
delta = self.thermostat['settings']['heatCoolMinDelta'] / 10
|
||||
heat_temp = temp - delta
|
||||
cool_temp = temp + delta
|
||||
self.set_auto_temp_hold(heat_temp, cool_temp)
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
@ -369,8 +397,8 @@ class Thermostat(ClimateDevice):
|
||||
high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
|
||||
if self.current_operation == STATE_AUTO and (low_temp is not None or
|
||||
high_temp is not None):
|
||||
if self.current_operation == STATE_AUTO and \
|
||||
(low_temp is not None or high_temp is not None):
|
||||
self.set_auto_temp_hold(low_temp, high_temp)
|
||||
elif temp is not None:
|
||||
self.set_temp_hold(temp)
|
||||
|
@ -15,7 +15,7 @@ from homeassistant.const import (
|
||||
CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['python-eq3bt==0.1.9']
|
||||
REQUIREMENTS = ['python-eq3bt==0.1.9', 'construct==2.9.41']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
153
homeassistant/components/climate/fritzbox.py
Executable file
153
homeassistant/components/climate/fritzbox.py
Executable file
@ -0,0 +1,153 @@
|
||||
"""
|
||||
Support for AVM Fritz!Box smarthome thermostate devices.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
http://home-assistant.io/components/climate.fritzbox/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN
|
||||
from homeassistant.components.fritzbox import (
|
||||
ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_BATTERY_LOW, ATTR_STATE_LOCKED)
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_OPERATION_MODE, ClimateDevice, STATE_ECO, STATE_HEAT, STATE_MANUAL,
|
||||
SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE, PRECISION_HALVES, TEMP_CELSIUS)
|
||||
|
||||
DEPENDENCIES = ['fritzbox']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE)
|
||||
|
||||
OPERATION_LIST = [STATE_HEAT, STATE_ECO]
|
||||
|
||||
MIN_TEMPERATURE = 8
|
||||
MAX_TEMPERATURE = 28
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Fritzbox smarthome thermostat platform."""
|
||||
devices = []
|
||||
fritz_list = hass.data[FRITZBOX_DOMAIN]
|
||||
|
||||
for fritz in fritz_list:
|
||||
device_list = fritz.get_devices()
|
||||
for device in device_list:
|
||||
if device.has_thermostat:
|
||||
devices.append(FritzboxThermostat(device, fritz))
|
||||
|
||||
add_devices(devices)
|
||||
|
||||
|
||||
class FritzboxThermostat(ClimateDevice):
|
||||
"""The thermostat class for Fritzbox smarthome thermostates."""
|
||||
|
||||
def __init__(self, device, fritz):
|
||||
"""Initialize the thermostat."""
|
||||
self._device = device
|
||||
self._fritz = fritz
|
||||
self._current_temperature = self._device.actual_temperature
|
||||
self._target_temperature = self._device.target_temperature
|
||||
self._comfort_temperature = self._device.comfort_temperature
|
||||
self._eco_temperature = self._device.eco_temperature
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_FLAGS
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return if thermostat is available."""
|
||||
return self._device.present
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._device.name
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement that is used."""
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
"""Return precision 0.5."""
|
||||
return PRECISION_HALVES
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
if ATTR_OPERATION_MODE in kwargs:
|
||||
operation_mode = kwargs.get(ATTR_OPERATION_MODE)
|
||||
self.set_operation_mode(operation_mode)
|
||||
elif ATTR_TEMPERATURE in kwargs:
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
self._device.set_target_temperature(temperature)
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return the current operation mode."""
|
||||
if self._target_temperature == self._comfort_temperature:
|
||||
return STATE_HEAT
|
||||
elif self._target_temperature == self._eco_temperature:
|
||||
return STATE_ECO
|
||||
return STATE_MANUAL
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""Return the list of available operation modes."""
|
||||
return OPERATION_LIST
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set new operation mode."""
|
||||
if operation_mode == STATE_HEAT:
|
||||
self.set_temperature(temperature=self._comfort_temperature)
|
||||
elif operation_mode == STATE_ECO:
|
||||
self.set_temperature(temperature=self._eco_temperature)
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return MIN_TEMPERATURE
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return MAX_TEMPERATURE
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device specific state attributes."""
|
||||
attrs = {
|
||||
ATTR_STATE_DEVICE_LOCKED: self._device.device_lock,
|
||||
ATTR_STATE_LOCKED: self._device.lock,
|
||||
ATTR_STATE_BATTERY_LOW: self._device.battery_low,
|
||||
}
|
||||
return attrs
|
||||
|
||||
def update(self):
|
||||
"""Update the data from the thermostat."""
|
||||
try:
|
||||
self._device.update()
|
||||
self._current_temperature = self._device.actual_temperature
|
||||
self._target_temperature = self._device.target_temperature
|
||||
self._comfort_temperature = self._device.comfort_temperature
|
||||
self._eco_temperature = self._device.eco_temperature
|
||||
except requests.exceptions.HTTPError as ex:
|
||||
_LOGGER.warning("Fritzbox connection error: %s", ex)
|
||||
self._fritz.login()
|
@ -14,7 +14,8 @@ from homeassistant.core import DOMAIN as HA_DOMAIN
|
||||
from homeassistant.components.climate import (
|
||||
STATE_HEAT, STATE_COOL, STATE_IDLE, STATE_AUTO, ClimateDevice,
|
||||
ATTR_OPERATION_MODE, ATTR_AWAY_MODE, SUPPORT_OPERATION_MODE,
|
||||
SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA)
|
||||
SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA,
|
||||
DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP)
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE,
|
||||
CONF_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
||||
@ -267,8 +268,7 @@ class GenericThermostat(ClimateDevice):
|
||||
if self._min_temp:
|
||||
return self._min_temp
|
||||
|
||||
# get default temp from super class
|
||||
return ClimateDevice.min_temp.fget(self)
|
||||
return DEFAULT_MIN_TEMP
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
@ -277,8 +277,7 @@ class GenericThermostat(ClimateDevice):
|
||||
if self._max_temp:
|
||||
return self._max_temp
|
||||
|
||||
# Get default temp from super class
|
||||
return ClimateDevice.max_temp.fget(self)
|
||||
return DEFAULT_MAX_TEMP
|
||||
|
||||
@asyncio.coroutine
|
||||
def _async_sensor_changed(self, entity_id, old_state, new_state):
|
||||
|
@ -38,7 +38,10 @@ class HiveClimateEntity(ClimateDevice):
|
||||
self.node_id = hivedevice["Hive_NodeID"]
|
||||
self.node_name = hivedevice["Hive_NodeName"]
|
||||
self.device_type = hivedevice["HA_DeviceType"]
|
||||
if self.device_type == "Heating":
|
||||
self.thermostat_node_id = hivedevice["Thermostat_NodeID"]
|
||||
self.session = hivesession
|
||||
self.attributes = {}
|
||||
self.data_updatesource = '{}.{}'.format(self.device_type,
|
||||
self.node_id)
|
||||
|
||||
@ -71,6 +74,11 @@ class HiveClimateEntity(ClimateDevice):
|
||||
friendly_name = "Hot Water"
|
||||
return friendly_name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Show Device Attributes."""
|
||||
return self.attributes
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
@ -175,4 +183,9 @@ class HiveClimateEntity(ClimateDevice):
|
||||
|
||||
def update(self):
|
||||
"""Update all Node data from Hive."""
|
||||
node = self.node_id
|
||||
if self.device_type == "Heating":
|
||||
node = self.thermostat_node_id
|
||||
|
||||
self.session.core.update_data(self.node_id)
|
||||
self.attributes = self.session.attributes.state_attributes(node)
|
||||
|
101
homeassistant/components/climate/homematicip_cloud.py
Normal file
101
homeassistant/components/climate/homematicip_cloud.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""
|
||||
Support for HomematicIP climate.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.homematicip_cloud/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice, SUPPORT_TARGET_TEMPERATURE, ATTR_TEMPERATURE,
|
||||
STATE_AUTO, STATE_MANUAL)
|
||||
from homeassistant.const import TEMP_CELSIUS
|
||||
from homeassistant.components.homematicip_cloud import (
|
||||
HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN,
|
||||
ATTR_HOME_ID)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STATE_BOOST = 'Boost'
|
||||
|
||||
HA_STATE_TO_HMIP = {
|
||||
STATE_AUTO: 'AUTOMATIC',
|
||||
STATE_MANUAL: 'MANUAL',
|
||||
}
|
||||
|
||||
HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()}
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the HomematicIP climate devices."""
|
||||
from homematicip.group import HeatingGroup
|
||||
|
||||
if discovery_info is None:
|
||||
return
|
||||
home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]]
|
||||
|
||||
devices = []
|
||||
for device in home.groups:
|
||||
if isinstance(device, HeatingGroup):
|
||||
devices.append(HomematicipHeatingGroup(home, device))
|
||||
|
||||
if devices:
|
||||
async_add_devices(devices)
|
||||
|
||||
|
||||
class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice):
|
||||
"""Representation of a MomematicIP heating group."""
|
||||
|
||||
def __init__(self, home, device):
|
||||
"""Initialize heating group."""
|
||||
device.modelType = 'Group-Heating'
|
||||
super().__init__(home, device)
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_TARGET_TEMPERATURE
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._device.setPointTemperature
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._device.actualTemperature
|
||||
|
||||
@property
|
||||
def current_humidity(self):
|
||||
"""Return the current humidity."""
|
||||
return self._device.humidity
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation ie. automatic or manual."""
|
||||
return HMIP_STATE_TO_HA.get(self._device.controlMode)
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return self._device.minTemperature
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return self._device.maxTemperature
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temperature is None:
|
||||
return
|
||||
await self._device.set_point_temperature(temperature)
|
@ -20,7 +20,7 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
|
||||
ATTR_TEMPERATURE, CONF_REGION)
|
||||
|
||||
REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.5.0']
|
||||
REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.5.2']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -10,7 +10,7 @@ import logging
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice, STATE_AUTO, SUPPORT_TARGET_TEMPERATURE,
|
||||
SUPPORT_OPERATION_MODE)
|
||||
from homeassistant.components.maxcube import MAXCUBE_HANDLE
|
||||
from homeassistant.components.maxcube import DATA_KEY
|
||||
from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -24,16 +24,16 @@ SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Iterate through all MAX! Devices and add thermostats."""
|
||||
cube = hass.data[MAXCUBE_HANDLE].cube
|
||||
|
||||
devices = []
|
||||
|
||||
for handler in hass.data[DATA_KEY].values():
|
||||
cube = handler.cube
|
||||
for device in cube.devices:
|
||||
name = '{} {}'.format(
|
||||
cube.room_by_id(device.room_id).name, device.name)
|
||||
|
||||
if cube.is_thermostat(device) or cube.is_wallthermostat(device):
|
||||
devices.append(MaxCubeClimate(hass, name, device.rf_address))
|
||||
devices.append(
|
||||
MaxCubeClimate(handler, name, device.rf_address))
|
||||
|
||||
if devices:
|
||||
add_devices(devices)
|
||||
@ -42,14 +42,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
class MaxCubeClimate(ClimateDevice):
|
||||
"""MAX! Cube ClimateDevice."""
|
||||
|
||||
def __init__(self, hass, name, rf_address):
|
||||
def __init__(self, handler, name, rf_address):
|
||||
"""Initialize MAX! Cube ClimateDevice."""
|
||||
self._name = name
|
||||
self._unit_of_measurement = TEMP_CELSIUS
|
||||
self._operation_list = [STATE_AUTO, STATE_MANUAL, STATE_BOOST,
|
||||
STATE_VACATION]
|
||||
self._rf_address = rf_address
|
||||
self._cubehandle = hass.data[MAXCUBE_HANDLE]
|
||||
self._cubehandle = handler
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
|
148
homeassistant/components/climate/modbus.py
Normal file
148
homeassistant/components/climate/modbus.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""
|
||||
Platform for a Generic Modbus Thermostat.
|
||||
|
||||
This uses a setpoint and process
|
||||
value within the controller, so both the current temperature register and the
|
||||
target temperature register need to be configured.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.modbus/
|
||||
"""
|
||||
import logging
|
||||
import struct
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_SLAVE, ATTR_TEMPERATURE)
|
||||
from homeassistant.components.climate import (
|
||||
ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE)
|
||||
|
||||
import homeassistant.components.modbus as modbus
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DEPENDENCIES = ['modbus']
|
||||
|
||||
# Parameters not defined by homeassistant.const
|
||||
CONF_TARGET_TEMP = 'target_temp_register'
|
||||
CONF_CURRENT_TEMP = 'current_temp_register'
|
||||
CONF_DATA_TYPE = 'data_type'
|
||||
CONF_COUNT = 'data_count'
|
||||
CONF_PRECISION = 'precision'
|
||||
|
||||
DATA_TYPE_INT = 'int'
|
||||
DATA_TYPE_UINT = 'uint'
|
||||
DATA_TYPE_FLOAT = 'float'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_SLAVE): cv.positive_int,
|
||||
vol.Required(CONF_TARGET_TEMP): cv.positive_int,
|
||||
vol.Required(CONF_CURRENT_TEMP): cv.positive_int,
|
||||
vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT):
|
||||
vol.In([DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]),
|
||||
vol.Optional(CONF_COUNT, default=2): cv.positive_int,
|
||||
vol.Optional(CONF_PRECISION, default=1): cv.positive_int
|
||||
})
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Modbus Thermostat Platform."""
|
||||
name = config.get(CONF_NAME)
|
||||
modbus_slave = config.get(CONF_SLAVE)
|
||||
target_temp_register = config.get(CONF_TARGET_TEMP)
|
||||
current_temp_register = config.get(CONF_CURRENT_TEMP)
|
||||
data_type = config.get(CONF_DATA_TYPE)
|
||||
count = config.get(CONF_COUNT)
|
||||
precision = config.get(CONF_PRECISION)
|
||||
|
||||
add_devices([ModbusThermostat(name, modbus_slave,
|
||||
target_temp_register, current_temp_register,
|
||||
data_type, count, precision)], True)
|
||||
|
||||
|
||||
class ModbusThermostat(ClimateDevice):
|
||||
"""Representation of a Modbus Thermostat."""
|
||||
|
||||
def __init__(self, name, modbus_slave, target_temp_register,
|
||||
current_temp_register, data_type, count, precision):
|
||||
"""Initialize the unit."""
|
||||
self._name = name
|
||||
self._slave = modbus_slave
|
||||
self._target_temperature_register = target_temp_register
|
||||
self._current_temperature_register = current_temp_register
|
||||
self._target_temperature = None
|
||||
self._current_temperature = None
|
||||
self._data_type = data_type
|
||||
self._count = int(count)
|
||||
self._precision = precision
|
||||
self._structure = '>f'
|
||||
|
||||
data_types = {DATA_TYPE_INT: {1: 'h', 2: 'i', 4: 'q'},
|
||||
DATA_TYPE_UINT: {1: 'H', 2: 'I', 4: 'Q'},
|
||||
DATA_TYPE_FLOAT: {1: 'e', 2: 'f', 4: 'd'}}
|
||||
|
||||
self._structure = '>{}'.format(data_types[self._data_type]
|
||||
[self._count])
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_FLAGS
|
||||
|
||||
def update(self):
|
||||
"""Update Target & Current Temperature."""
|
||||
self._target_temperature = self.read_register(
|
||||
self._target_temperature_register)
|
||||
self._current_temperature = self.read_register(
|
||||
self._current_temperature_register)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the climate device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
target_temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
if target_temperature is None:
|
||||
return
|
||||
byte_string = struct.pack(self._structure, target_temperature)
|
||||
register_value = struct.unpack('>h', byte_string[0:2])[0]
|
||||
|
||||
try:
|
||||
self.write_register(self._target_temperature_register,
|
||||
register_value)
|
||||
except AttributeError as ex:
|
||||
_LOGGER.error(ex)
|
||||
|
||||
def read_register(self, register):
|
||||
"""Read holding register using the modbus hub slave."""
|
||||
try:
|
||||
result = modbus.HUB.read_holding_registers(self._slave, register,
|
||||
self._count)
|
||||
except AttributeError as ex:
|
||||
_LOGGER.error(ex)
|
||||
byte_string = b''.join(
|
||||
[x.to_bytes(2, byteorder='big') for x in result.registers])
|
||||
val = struct.unpack(self._structure, byte_string)[0]
|
||||
register_value = format(val, '.{}f'.format(self._precision))
|
||||
return register_value
|
||||
|
||||
def write_register(self, register, value):
|
||||
"""Write register using the modbus hub slave."""
|
||||
modbus.HUB.write_registers(self._slave, register, [value, 0])
|
@ -31,10 +31,12 @@ SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH |
|
||||
SUPPORT_OPERATION_MODE)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the MySensors climate."""
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the mysensors climate."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass, DOMAIN, discovery_info, MySensorsHVAC, add_devices=add_devices)
|
||||
hass, DOMAIN, discovery_info, MySensorsHVAC,
|
||||
async_add_devices=async_add_devices)
|
||||
|
||||
|
||||
class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice):
|
||||
@ -113,7 +115,7 @@ class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice):
|
||||
"""List of available fan modes."""
|
||||
return ['Auto', 'Min', 'Normal', 'Max']
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
set_req = self.gateway.const.SetReq
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
@ -141,9 +143,9 @@ class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice):
|
||||
if self.gateway.optimistic:
|
||||
# Optimistically assume that device has changed state
|
||||
self._values[value_type] = value
|
||||
self.schedule_update_ha_state()
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
def set_fan_mode(self, fan_mode):
|
||||
async def async_set_fan_mode(self, fan_mode):
|
||||
"""Set new target temperature."""
|
||||
set_req = self.gateway.const.SetReq
|
||||
self.gateway.set_child_value(
|
||||
@ -151,9 +153,9 @@ class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice):
|
||||
if self.gateway.optimistic:
|
||||
# Optimistically assume that device has changed state
|
||||
self._values[set_req.V_HVAC_SPEED] = fan_mode
|
||||
self.schedule_update_ha_state()
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
async def async_set_operation_mode(self, operation_mode):
|
||||
"""Set new target temperature."""
|
||||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, self.value_type,
|
||||
@ -161,10 +163,10 @@ class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice):
|
||||
if self.gateway.optimistic:
|
||||
# Optimistically assume that device has changed state
|
||||
self._values[self.value_type] = operation_mode
|
||||
self.schedule_update_ha_state()
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
def update(self):
|
||||
async def async_update(self):
|
||||
"""Update the controller with the latest value from a sensor."""
|
||||
super().update()
|
||||
await super().async_update()
|
||||
self._values[self.value_type] = DICT_MYS_TO_HA[
|
||||
self._values[self.value_type]]
|
||||
|
@ -8,7 +8,7 @@ import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.nest import DATA_NEST
|
||||
from homeassistant.components.nest import DATA_NEST, SIGNAL_NEST_UPDATE
|
||||
from homeassistant.components.climate import (
|
||||
STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_ECO, ClimateDevice,
|
||||
PLATFORM_SCHEMA, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
|
||||
@ -18,6 +18,7 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import (
|
||||
TEMP_CELSIUS, TEMP_FAHRENHEIT,
|
||||
CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
DEPENDENCIES = ['nest']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -37,11 +38,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
|
||||
temp_unit = hass.config.units.temperature_unit
|
||||
|
||||
add_devices(
|
||||
[NestThermostat(structure, device, temp_unit)
|
||||
for structure, device in hass.data[DATA_NEST].thermostats()],
|
||||
True
|
||||
)
|
||||
all_devices = [NestThermostat(structure, device, temp_unit)
|
||||
for structure, device in hass.data[DATA_NEST].thermostats()]
|
||||
|
||||
add_devices(all_devices, True)
|
||||
|
||||
|
||||
class NestThermostat(ClimateDevice):
|
||||
@ -97,6 +97,20 @@ class NestThermostat(ClimateDevice):
|
||||
self._min_temperature = None
|
||||
self._max_temperature = None
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Do not need poll thanks using Nest streaming API."""
|
||||
return False
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register update signal handler."""
|
||||
async def async_update_state():
|
||||
"""Update device state."""
|
||||
await self.async_update_ha_state(True)
|
||||
|
||||
async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE,
|
||||
async_update_state)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
@ -134,7 +148,9 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
if self._mode != NEST_MODE_HEAT_COOL and not self.is_away_mode_on:
|
||||
if self._mode != NEST_MODE_HEAT_COOL and \
|
||||
self._mode != STATE_ECO and \
|
||||
not self.is_away_mode_on:
|
||||
return self._target_temperature
|
||||
return None
|
||||
|
||||
@ -168,18 +184,24 @@ class NestThermostat(ClimateDevice):
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
import nest
|
||||
temp = None
|
||||
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
||||
target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
||||
if self._mode == NEST_MODE_HEAT_COOL:
|
||||
if target_temp_low is not None and target_temp_high is not None:
|
||||
temp = (target_temp_low, target_temp_high)
|
||||
_LOGGER.debug("Nest set_temperature-output-value=%s", temp)
|
||||
else:
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
_LOGGER.debug("Nest set_temperature-output-value=%s", temp)
|
||||
try:
|
||||
if temp is not None:
|
||||
self.device.target = temp
|
||||
except nest.nest.APIError:
|
||||
_LOGGER.error("An error occured while setting the temperature")
|
||||
except nest.nest.APIError as api_error:
|
||||
_LOGGER.error("An error occurred while setting temperature: %s",
|
||||
api_error)
|
||||
# restore target temperature
|
||||
self.schedule_update_ha_state(True)
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
@ -187,6 +209,11 @@ class NestThermostat(ClimateDevice):
|
||||
device_mode = operation_mode
|
||||
elif operation_mode == STATE_AUTO:
|
||||
device_mode = NEST_MODE_HEAT_COOL
|
||||
else:
|
||||
device_mode = STATE_OFF
|
||||
_LOGGER.error(
|
||||
"An error occurred while setting device mode. "
|
||||
"Invalid operation mode: %s", operation_mode)
|
||||
self.device.mode = device_mode
|
||||
|
||||
@property
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user